From 9aba06ec7df12db0d76b0cfee6c22aabb79b7e32 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 18 Jun 2026 11:37:06 +0000 Subject: [PATCH] integration: backfill CLI command coverage Regroup the CLI tests into per-command files and cover every command except debug/config with CRUD, flag and error permutations, asserted as JSON. --- integration/cli_apikeys_test.go | 320 +++++ integration/cli_auth_test.go | 49 + integration/cli_nodes_test.go | 1236 +++++++++++++++++++ integration/cli_policy_test.go | 164 +++ integration/cli_preauthkeys_test.go | 397 ++++++ integration/cli_server_test.go | 47 + integration/cli_test.go | 1779 +-------------------------- integration/cli_users_test.go | 339 +++++ 8 files changed, 2601 insertions(+), 1730 deletions(-) create mode 100644 integration/cli_apikeys_test.go create mode 100644 integration/cli_auth_test.go create mode 100644 integration/cli_nodes_test.go create mode 100644 integration/cli_preauthkeys_test.go create mode 100644 integration/cli_server_test.go create mode 100644 integration/cli_users_test.go diff --git a/integration/cli_apikeys_test.go b/integration/cli_apikeys_test.go new file mode 100644 index 00000000..f42de600 --- /dev/null +++ b/integration/cli_apikeys_test.go @@ -0,0 +1,320 @@ +package integration + +import ( + "strconv" + "testing" + "time" + + v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/integrationutil" + "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApiKeyCommand(t *testing.T) { + IntegrationSkip(t) + + count := 5 + + spec := ScenarioSpec{ + Users: []string{"user1", "user2"}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-apikey")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + keys := make([]string, count) + + for idx := range count { + apiResult, err := headscale.Execute( + []string{ + "headscale", + "apikeys", + "create", + "--expiration", + "24h", + "--output", + "json", + }, + ) + require.NoError(t, err) + assert.NotEmpty(t, apiResult) + + keys[idx] = apiResult + } + + assert.Len(t, keys, 5) + + var listedAPIKeys []v1.ApiKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal(headscale, + []string{ + "headscale", + "apikeys", + "list", + "--output", + "json", + }, + &listedAPIKeys, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list") + + assert.Len(t, listedAPIKeys, 5) + + assert.Equal(t, uint64(1), listedAPIKeys[0].GetId()) + assert.Equal(t, uint64(2), listedAPIKeys[1].GetId()) + assert.Equal(t, uint64(3), listedAPIKeys[2].GetId()) + assert.Equal(t, uint64(4), listedAPIKeys[3].GetId()) + assert.Equal(t, uint64(5), listedAPIKeys[4].GetId()) + + assert.NotEmpty(t, listedAPIKeys[0].GetPrefix()) + assert.NotEmpty(t, listedAPIKeys[1].GetPrefix()) + assert.NotEmpty(t, listedAPIKeys[2].GetPrefix()) + assert.NotEmpty(t, listedAPIKeys[3].GetPrefix()) + assert.NotEmpty(t, listedAPIKeys[4].GetPrefix()) + + assert.True(t, listedAPIKeys[0].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedAPIKeys[1].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedAPIKeys[2].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedAPIKeys[3].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedAPIKeys[4].GetExpiration().AsTime().After(time.Now())) + + assert.True( + t, + listedAPIKeys[0].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + ) + assert.True( + t, + listedAPIKeys[1].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + ) + assert.True( + t, + listedAPIKeys[2].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + ) + assert.True( + t, + listedAPIKeys[3].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + ) + assert.True( + t, + listedAPIKeys[4].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + ) + + expiredPrefixes := make(map[string]bool) + + // Expire three keys + for idx := range 3 { + _, err := headscale.Execute( + []string{ + "headscale", + "apikeys", + "expire", + "--prefix", + listedAPIKeys[idx].GetPrefix(), + }, + ) + require.NoError(t, err) + + expiredPrefixes[listedAPIKeys[idx].GetPrefix()] = true + } + + var listedAfterExpireAPIKeys []v1.ApiKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal(headscale, + []string{ + "headscale", + "apikeys", + "list", + "--output", + "json", + }, + &listedAfterExpireAPIKeys, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list after expire") + + for index := range listedAfterExpireAPIKeys { + if _, ok := expiredPrefixes[listedAfterExpireAPIKeys[index].GetPrefix()]; ok { + // Expired + assert.True( + t, + listedAfterExpireAPIKeys[index].GetExpiration().AsTime().Before(time.Now()), + ) + } else { + // Not expired + assert.False( + t, + listedAfterExpireAPIKeys[index].GetExpiration().AsTime().Before(time.Now()), + ) + } + } + + _, err = headscale.Execute( + []string{ + "headscale", + "apikeys", + "delete", + "--prefix", + listedAPIKeys[0].GetPrefix(), + }) + require.NoError(t, err) + + var listedAPIKeysAfterDelete []v1.ApiKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal(headscale, + []string{ + "headscale", + "apikeys", + "list", + "--output", + "json", + }, + &listedAPIKeysAfterDelete, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list after delete") + + assert.Len(t, listedAPIKeysAfterDelete, 4) + + // Test expire by ID (using key at index 0) + _, err = headscale.Execute( + []string{ + "headscale", + "apikeys", + "expire", + "--id", + strconv.FormatUint(listedAPIKeysAfterDelete[0].GetId(), 10), + }) + require.NoError(t, err) + + var listedAPIKeysAfterExpireByID []v1.ApiKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal(headscale, + []string{ + "headscale", + "apikeys", + "list", + "--output", + "json", + }, + &listedAPIKeysAfterExpireByID, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list after expire by ID") + + // Verify the key was expired + for idx := range listedAPIKeysAfterExpireByID { + if listedAPIKeysAfterExpireByID[idx].GetId() == listedAPIKeysAfterDelete[0].GetId() { + assert.True(t, listedAPIKeysAfterExpireByID[idx].GetExpiration().AsTime().Before(time.Now()), + "Key expired by ID should have expiration in the past") + } + } + + // Test delete by ID (using key at index 1) + deletedKeyID := listedAPIKeysAfterExpireByID[1].GetId() + _, err = headscale.Execute( + []string{ + "headscale", + "apikeys", + "delete", + "--id", + strconv.FormatUint(deletedKeyID, 10), + }) + require.NoError(t, err) + + var listedAPIKeysAfterDeleteByID []v1.ApiKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal(headscale, + []string{ + "headscale", + "apikeys", + "list", + "--output", + "json", + }, + &listedAPIKeysAfterDeleteByID, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list after delete by ID") + + assert.Len(t, listedAPIKeysAfterDeleteByID, 3) + + // Verify the specific key was deleted + for idx := range listedAPIKeysAfterDeleteByID { + assert.NotEqual(t, deletedKeyID, listedAPIKeysAfterDeleteByID[idx].GetId(), + "Deleted key should not be present in the list") + } +} + +// TestApiKeyCommandValidation covers the validation permutations of +// `apikeys expire` and `apikeys delete`: the mutually-exclusive --id/--prefix +// flags (neither / both), a non-existent prefix, and an invalid --expiration on +// create. A real key is created so the "both flags" path has a valid prefix. +func TestApiKeyCommandValidation(t *testing.T) { + IntegrationSkip(t) + + scenario, headscale := setupCLIScenario(t, "cli-apikeyval", []string{"user1"}, 0) + defer scenario.ShutdownAssertNoPanics(t) + + // Create a real key so a valid prefix exists. `apikeys create` prints the + // raw secret, not JSON of the key, so list to discover the prefix. + _, err := headscale.Execute([]string{"headscale", "apikeys", "create", "--output", "json"}) + require.NoError(t, err) + + var listed []v1.ApiKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{"headscale", "apikeys", "list", "--output", "json"}, + &listed, + ) + assert.NoError(c, err) + assert.Len(c, listed, 1) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API key list") + + prefix := listed[0].GetPrefix() + id := strconv.FormatUint(listed[0].GetId(), 10) + + tests := []struct { + name string + args []string + wantErr string + }{ + {name: "create invalid expiration", args: []string{"apikeys", "create", "--expiration", "not-a-duration"}}, + {name: "expire neither selector", args: []string{"apikeys", "expire"}, wantErr: "either --id or --prefix must be provided"}, + {name: "expire both selectors", args: []string{"apikeys", "expire", "--id", id, "--prefix", prefix}, wantErr: "only one of --id or --prefix can be provided"}, + {name: "expire nonexistent prefix", args: []string{"apikeys", "expire", "--prefix", "nonexistent"}}, + {name: "delete neither selector", args: []string{"apikeys", "delete"}, wantErr: "either --id or --prefix must be provided"}, + {name: "delete both selectors", args: []string{"apikeys", "delete", "--id", id, "--prefix", prefix}, wantErr: "only one of --id or --prefix can be provided"}, + {name: "delete nonexistent id", args: []string{"apikeys", "delete", "--id", "99999"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := headscale.Execute(append([]string{"headscale"}, tt.args...)) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + + return + } + + require.Error(t, err) + }) + } +} diff --git a/integration/cli_auth_test.go b/integration/cli_auth_test.go new file mode 100644 index 00000000..e3ea96d7 --- /dev/null +++ b/integration/cli_auth_test.go @@ -0,0 +1,49 @@ +package integration + +import ( + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/require" +) + +// TestAuthCommandValidation exercises the validation permutations of the auth +// subcommands over the gRPC transport: `register` against a non-existent user +// and a malformed auth-id, and `approve`/`reject` against malformed and unknown +// auth-ids. +// +// approve/reject are only covered at this level: an approve carries an empty +// verdict that does not itself register a node — the waiting client is told to +// restart registration (hscontrol/auth.go waitForFollowup), so a driven +// web-login would hang. Completing an interactive registration is covered by +// `auth register` in the node tests and the web-auth flow tests. +func TestAuthCommandValidation(t *testing.T) { + IntegrationSkip(t) + + scenario, headscale := setupCLIScenario(t, "cli-authval", []string{"user1"}, 0) + defer scenario.ShutdownAssertNoPanics(t) + + // Well-formed (correct prefix/length) but unknown auth-id: the handler + // reaches the cache lookup and reports no pending session. + unknown := types.MustAuthID().String() + + tests := []struct { + name string + args []string + wantErr string + }{ + {name: "register malformed auth-id", args: []string{"auth", "register", "--user", "user1", "--auth-id", "not-valid"}, wantErr: "invalid"}, + {name: "register nonexistent user", args: []string{"auth", "register", "--user", "ghost", "--auth-id", unknown}, wantErr: "looking up user"}, + {name: "approve malformed auth-id", args: []string{"auth", "approve", "--auth-id", "not-valid"}, wantErr: "invalid auth_id"}, + {name: "approve unknown auth-id", args: []string{"auth", "approve", "--auth-id", unknown}, wantErr: "no pending auth session"}, + {name: "reject malformed auth-id", args: []string{"auth", "reject", "--auth-id", "not-valid"}, wantErr: "invalid auth_id"}, + {name: "reject unknown auth-id", args: []string{"auth", "reject", "--auth-id", unknown}, wantErr: "no pending auth session"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := headscale.Execute(append([]string{"headscale"}, tt.args...)) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/integration/cli_nodes_test.go b/integration/cli_nodes_test.go new file mode 100644 index 00000000..4ea21e4e --- /dev/null +++ b/integration/cli_nodes_test.go @@ -0,0 +1,1236 @@ +package integration + +import ( + "fmt" + "strconv" + "strings" + "testing" + "time" + + v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/integrationutil" + "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" +) + +func TestNodeCommand(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + Users: []string{"node-user", "other-user"}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-node")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + regIDs := []string{ + types.MustAuthID().String(), + types.MustAuthID().String(), + types.MustAuthID().String(), + types.MustAuthID().String(), + types.MustAuthID().String(), + } + nodes := make([]*v1.Node, len(regIDs)) + + require.NoError(t, err) + + for index, regID := range regIDs { + _, err := headscale.Execute( + []string{ + "headscale", + "debug", + "create-node", + "--name", + fmt.Sprintf("node-%d", index+1), + "--user", + "node-user", + "--key", + regID, + "--output", + "json", + }, + ) + require.NoError(t, err) + + var node v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "auth", + "register", + "--user", + "node-user", + "--auth-id", + regID, + "--output", + "json", + }, + &node, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node registration") + + nodes[index] = &node + } + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.Len(ct, nodes, len(regIDs), "Should have correct number of nodes after CLI operations") + }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second) + + // Test list all nodes after added seconds + var listAll []v1.Node + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + err := executeAndUnmarshal( + headscale, + []string{ + "headscale", + "nodes", + "list", + "--output", + "json", + }, + &listAll, + ) + assert.NoError(ct, err) + assert.Len(ct, listAll, len(regIDs), "Should list all nodes after CLI operations") + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) + + assert.Equal(t, uint64(1), listAll[0].GetId()) + assert.Equal(t, uint64(2), listAll[1].GetId()) + assert.Equal(t, uint64(3), listAll[2].GetId()) + assert.Equal(t, uint64(4), listAll[3].GetId()) + assert.Equal(t, uint64(5), listAll[4].GetId()) + + assert.Equal(t, "node-1", listAll[0].GetName()) + assert.Equal(t, "node-2", listAll[1].GetName()) + assert.Equal(t, "node-3", listAll[2].GetName()) + assert.Equal(t, "node-4", listAll[3].GetName()) + assert.Equal(t, "node-5", listAll[4].GetName()) + + otherUserRegIDs := []string{ + types.MustAuthID().String(), + types.MustAuthID().String(), + } + otherUserMachines := make([]*v1.Node, len(otherUserRegIDs)) + + require.NoError(t, err) + + for index, regID := range otherUserRegIDs { + _, err := headscale.Execute( + []string{ + "headscale", + "debug", + "create-node", + "--name", + fmt.Sprintf("otheruser-node-%d", index+1), + "--user", + "other-user", + "--key", + regID, + "--output", + "json", + }, + ) + require.NoError(t, err) + + var node v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "auth", + "register", + "--user", + "other-user", + "--auth-id", + regID, + "--output", + "json", + }, + &node, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for other-user node registration") + + otherUserMachines[index] = &node + } + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.Len(ct, otherUserMachines, len(otherUserRegIDs), "Should have correct number of otherUser machines after CLI operations") + }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second) + + // Test list all nodes after added otherUser + var listAllWithotherUser []v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "nodes", + "list", + "--output", + "json", + }, + &listAllWithotherUser, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list after adding other-user nodes") + + // All nodes, nodes + otherUser + assert.Len(t, listAllWithotherUser, 7) + + assert.Equal(t, uint64(6), listAllWithotherUser[5].GetId()) + assert.Equal(t, uint64(7), listAllWithotherUser[6].GetId()) + + assert.Equal(t, "otheruser-node-1", listAllWithotherUser[5].GetName()) + assert.Equal(t, "otheruser-node-2", listAllWithotherUser[6].GetName()) + + // Test list all nodes after added otherUser + var listOnlyotherUserMachineUser []v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "nodes", + "list", + "--user", + "other-user", + "--output", + "json", + }, + &listOnlyotherUserMachineUser, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list filtered by other-user") + + assert.Len(t, listOnlyotherUserMachineUser, 2) + + assert.Equal(t, uint64(6), listOnlyotherUserMachineUser[0].GetId()) + assert.Equal(t, uint64(7), listOnlyotherUserMachineUser[1].GetId()) + + assert.Equal( + t, + "otheruser-node-1", + listOnlyotherUserMachineUser[0].GetName(), + ) + assert.Equal( + t, + "otheruser-node-2", + listOnlyotherUserMachineUser[1].GetName(), + ) + + // Delete a nodes + _, err = headscale.Execute( + []string{ + "headscale", + "nodes", + "delete", + "--identifier", + // Delete the last added machine + "4", + "--output", + "json", + "--force", + }, + ) + require.NoError(t, err) + + // Test: list main user after node is deleted + var listOnlyMachineUserAfterDelete []v1.Node + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + err := executeAndUnmarshal( + headscale, + []string{ + "headscale", + "nodes", + "list", + "--user", + "node-user", + "--output", + "json", + }, + &listOnlyMachineUserAfterDelete, + ) + assert.NoError(ct, err) + assert.Len(ct, listOnlyMachineUserAfterDelete, 4, "Should have 4 nodes for node-user after deletion") + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) +} + +func TestNodeExpireCommand(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + Users: []string{"node-expire-user"}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-nodeexpire")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + regIDs := []string{ + types.MustAuthID().String(), + types.MustAuthID().String(), + types.MustAuthID().String(), + types.MustAuthID().String(), + types.MustAuthID().String(), + } + nodes := make([]*v1.Node, len(regIDs)) + + for index, regID := range regIDs { + _, err := headscale.Execute( + []string{ + "headscale", + "debug", + "create-node", + "--name", + fmt.Sprintf("node-%d", index+1), + "--user", + "node-expire-user", + "--key", + regID, + "--output", + "json", + }, + ) + require.NoError(t, err) + + var node v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "auth", + "register", + "--user", + "node-expire-user", + "--auth-id", + regID, + "--output", + "json", + }, + &node, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node-expire-user node registration") + + nodes[index] = &node + } + + assert.Len(t, nodes, len(regIDs)) + + var listAll []v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "nodes", + "list", + "--output", + "json", + }, + &listAll, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list in expire test") + + assert.Len(t, listAll, 5) + + // With node.expiry defaulting to 0, non-tagged nodes have zero expiry + // (never expire unless explicitly expired). + for i := range 5 { + assert.True(t, listAll[i].GetExpiry().AsTime().IsZero(), + "node %d should have zero expiry (no default node.expiry)", i) + } + + for idx := range 3 { + _, err := headscale.Execute( + []string{ + "headscale", + "nodes", + "expire", + "--identifier", + strconv.FormatUint(listAll[idx].GetId(), 10), + }, + ) + require.NoError(t, err) + } + + var listAllAfterExpiry []v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "nodes", + "list", + "--output", + "json", + }, + &listAllAfterExpiry, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list after expiry") + + assert.Len(t, listAllAfterExpiry, 5) + + assert.True(t, listAllAfterExpiry[0].GetExpiry().AsTime().Before(time.Now())) + assert.True(t, listAllAfterExpiry[1].GetExpiry().AsTime().Before(time.Now())) + assert.True(t, listAllAfterExpiry[2].GetExpiry().AsTime().Before(time.Now())) + assert.True(t, listAllAfterExpiry[3].GetExpiry().AsTime().IsZero()) + assert.True(t, listAllAfterExpiry[4].GetExpiry().AsTime().IsZero()) +} + +func TestNodeRenameCommand(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + Users: []string{"node-rename-command"}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-noderename")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + regIDs := []string{ + types.MustAuthID().String(), + types.MustAuthID().String(), + types.MustAuthID().String(), + types.MustAuthID().String(), + types.MustAuthID().String(), + } + nodes := make([]*v1.Node, len(regIDs)) + + require.NoError(t, err) + + for index, regID := range regIDs { + _, err := headscale.Execute( + []string{ + "headscale", + "debug", + "create-node", + "--name", + fmt.Sprintf("node-%d", index+1), + "--user", + "node-rename-command", + "--key", + regID, + "--output", + "json", + }, + ) + require.NoError(t, err) + + var node v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "auth", + "register", + "--user", + "node-rename-command", + "--auth-id", + regID, + "--output", + "json", + }, + &node, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node-rename-command node registration") + + nodes[index] = &node + } + + assert.Len(t, nodes, len(regIDs)) + + var listAll []v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "nodes", + "list", + "--output", + "json", + }, + &listAll, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list in rename test") + + assert.Len(t, listAll, 5) + + assert.Contains(t, listAll[0].GetGivenName(), "node-1") + assert.Contains(t, listAll[1].GetGivenName(), "node-2") + assert.Contains(t, listAll[2].GetGivenName(), "node-3") + assert.Contains(t, listAll[3].GetGivenName(), "node-4") + assert.Contains(t, listAll[4].GetGivenName(), "node-5") + + for idx := range 3 { + res, err := headscale.Execute( + []string{ + "headscale", + "nodes", + "rename", + "--identifier", + strconv.FormatUint(listAll[idx].GetId(), 10), + fmt.Sprintf("newnode-%d", idx+1), + }, + ) + require.NoError(t, err) + + assert.Contains(t, res, "Node renamed") + } + + var listAllAfterRename []v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "nodes", + "list", + "--output", + "json", + }, + &listAllAfterRename, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list after rename") + + assert.Len(t, listAllAfterRename, 5) + + assert.Equal(t, "newnode-1", listAllAfterRename[0].GetGivenName()) + assert.Equal(t, "newnode-2", listAllAfterRename[1].GetGivenName()) + assert.Equal(t, "newnode-3", listAllAfterRename[2].GetGivenName()) + assert.Contains(t, listAllAfterRename[3].GetGivenName(), "node-4") + assert.Contains(t, listAllAfterRename[4].GetGivenName(), "node-5") + + // Test failure for too long names + _, err = headscale.Execute( + []string{ + "headscale", + "nodes", + "rename", + "--identifier", + strconv.FormatUint(listAll[4].GetId(), 10), + strings.Repeat("t", 64), + }, + ) + require.ErrorContains(t, err, "is too long, max length is 63 bytes") + + var listAllAfterRenameAttempt []v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "nodes", + "list", + "--output", + "json", + }, + &listAllAfterRenameAttempt, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list after failed rename attempt") + + assert.Len(t, listAllAfterRenameAttempt, 5) + + assert.Equal(t, "newnode-1", listAllAfterRenameAttempt[0].GetGivenName()) + assert.Equal(t, "newnode-2", listAllAfterRenameAttempt[1].GetGivenName()) + assert.Equal(t, "newnode-3", listAllAfterRenameAttempt[2].GetGivenName()) + assert.Contains(t, listAllAfterRenameAttempt[3].GetGivenName(), "node-4") + assert.Contains(t, listAllAfterRenameAttempt[4].GetGivenName(), "node-5") +} + +func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { + IntegrationSkip(t) + + //nolint:goconst // test data, not worth extracting + user1 := "user1" + //nolint:goconst // test data, not worth extracting + user2 := "user2" + + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{user1}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("cli-paklogin"), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + u2, err := headscale.CreateUser(user2) + require.NoError(t, err) + + var user2Key v1.PreAuthKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "--user", + strconv.FormatUint(u2.GetId(), 10), + "create", + "--reusable", + "--expiration", + "24h", + "--output", + "json", + "--tags", + "tag:test1,tag:test2", + }, + &user2Key, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user2 preauth key creation") + + var listNodes []*v1.Node + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + var err error + + listNodes, err = headscale.ListNodes() + assert.NoError(ct, err) + assert.Len(ct, listNodes, 1, "Should have exactly 1 node for user1") + assert.Equal(ct, user1, listNodes[0].GetUser().GetName(), "Node should belong to user1") + }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second) + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + + require.Len(t, allClients, 1) + + client := allClients[0] + + // Log out from user1 + err = client.Logout() + require.NoError(t, err) + + err = scenario.WaitForTailscaleLogout() + require.NoError(t, err) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + status, err := client.Status() + assert.NoError(ct, err) + assert.NotContains(ct, []string{"Starting", "Running"}, status.BackendState, + "Expected node to be logged out, backend state: %s", status.BackendState) + }, integrationutil.StatusReadyTimeout, 2*time.Second) + + err = client.Login(headscale.GetEndpoint(), user2Key.GetKey()) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + status, err := client.Status() + assert.NoError(ct, err) + assert.Equal(ct, "Running", status.BackendState, "Expected node to be logged in, backend state: %s", status.BackendState) + // With tags-as-identity model, tagged nodes show as [types.TaggedDevices] user (2147455555) + // The PreAuthKey was created with tags, so the node is tagged + assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user") + }, integrationutil.StatusReadyTimeout, 2*time.Second) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + var err error + + listNodes, err = headscale.ListNodes() + assert.NoError(ct, err) + assert.Len(ct, listNodes, 2, "Should have 2 nodes after re-login") + assert.Equal(ct, user1, listNodes[0].GetUser().GetName(), "First node should belong to user1") + // Second node is tagged (created with tagged PreAuthKey), so it shows as "tagged-devices" + assert.Equal(ct, "tagged-devices", listNodes[1].GetUser().GetName(), "Second node should be tagged-devices") + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) +} + +func TestTaggedNodesCLIOutput(t *testing.T) { + IntegrationSkip(t) + + user1 := "user1" + user2 := "user2" + + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{user1}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("tagcli"), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + u2, err := headscale.CreateUser(user2) + require.NoError(t, err) + + var user2Key v1.PreAuthKey + + // Create a tagged PreAuthKey for user2 + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "--user", + strconv.FormatUint(u2.GetId(), 10), + "create", + "--reusable", + "--expiration", + "24h", + "--output", + "json", + "--tags", + "tag:test1,tag:test2", + }, + &user2Key, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user2 tagged preauth key creation") + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + + require.Len(t, allClients, 1) + + client := allClients[0] + + // Log out from user1 + err = client.Logout() + require.NoError(t, err) + + err = scenario.WaitForTailscaleLogout() + require.NoError(t, err) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + status, err := client.Status() + assert.NoError(ct, err) + assert.NotContains(ct, []string{"Starting", "Running"}, status.BackendState, + "Expected node to be logged out, backend state: %s", status.BackendState) + }, integrationutil.StatusReadyTimeout, 2*time.Second) + + // Log in with the tagged PreAuthKey (from user2, with tags) + err = client.Login(headscale.GetEndpoint(), user2Key.GetKey()) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + status, err := client.Status() + assert.NoError(ct, err) + assert.Equal(ct, "Running", status.BackendState, "Expected node to be logged in, backend state: %s", status.BackendState) + // With tags-as-identity model, tagged nodes show as [types.TaggedDevices] user (2147455555) + assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user") + }, integrationutil.StatusReadyTimeout, 2*time.Second) + + // Wait for the second node to appear + var listNodes []*v1.Node + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + var err error + + listNodes, err = headscale.ListNodes() + assert.NoError(ct, err) + assert.Len(ct, listNodes, 2, "Should have 2 nodes after re-login with tagged key") + assert.Equal(ct, user1, listNodes[0].GetUser().GetName(), "First node should belong to user1") + assert.Equal(ct, "tagged-devices", listNodes[1].GetUser().GetName(), "Second node should be tagged-devices") + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) + + // Test: tailscale status output should show "tagged-devices" not "userid:2147455555" + // This is the fix for issue #2970 - the Tailscale client should display user-friendly names + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + stdout, stderr, err := client.Execute([]string{"tailscale", "status"}) + assert.NoError(ct, err, "tailscale status command should succeed, stderr: %s", stderr) + + t.Logf("Tailscale status output:\n%s", stdout) + + // The output should contain "tagged-devices" for tagged nodes + assert.Contains(ct, stdout, "tagged-devices", "Tailscale status should show 'tagged-devices' for tagged nodes") + + // The output should NOT show the raw numeric userid to the user + assert.NotContains(ct, stdout, "userid:2147455555", "Tailscale status should not show numeric userid for tagged nodes") + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) +} + +// TestNodeExpireFlagsCommand covers the two `nodes expire` flags that the basic +// expire test does not: --expiry (set a future expiry) and --disable (clear +// expiry so the node never expires). +func TestNodeExpireFlagsCommand(t *testing.T) { + IntegrationSkip(t) + + scenario, headscale := setupCLIScenario(t, "cli-nodeexpireflags", []string{"expire-flags-user"}, 0) + defer scenario.ShutdownAssertNoPanics(t) + + regID := types.MustAuthID().String() + + _, err := headscale.Execute([]string{ + "headscale", "debug", "create-node", + "--name", "flagnode", + "--user", "expire-flags-user", + "--key", regID, + "--output", "json", + }) + require.NoError(t, err) + + var node v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{ + "headscale", "auth", "register", + "--user", "expire-flags-user", + "--auth-id", regID, + "--output", "json", + }, + &node, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node registration") + + nodeID := strconv.FormatUint(node.GetId(), 10) + + // listNodeByID returns the node with the given id from `nodes list`. The + // expire mutations are verified by reading the node back (authoritative, + // eventually-consistent) rather than trusting the mutation's immediate + // response. + listNodeByID := func(ct *assert.CollectT) *v1.Node { + var nodes []v1.Node + + err := executeAndUnmarshal(headscale, + []string{"headscale", "nodes", "list", "--output", "json"}, + &nodes, + ) + require.NoError(ct, err) + + for i := range nodes { + if nodes[i].GetId() == node.GetId() { + return &nodes[i] + } + } + + assert.Fail(ct, "node not found in list", "id %s", nodeID) + + return nil + } + + // Set a future expiry, then confirm the node reports it. + future := time.Now().Add(2 * time.Hour).UTC() + + _, err = headscale.Execute([]string{ + "headscale", "nodes", "expire", + "--identifier", nodeID, + "--expiry", future.Format(time.RFC3339), + "--output", "json", + }) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + n := listNodeByID(ct) + if n == nil { + return + } + + assert.False(ct, n.GetExpiry().AsTime().IsZero(), "expiry should be set") + assert.True(ct, n.GetExpiry().AsTime().After(time.Now()), "expiry should be in the future") + }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second, "Waiting for future expiry to apply") + + // Disable expiry entirely; the node should then report no expiry. + _, err = headscale.Execute([]string{ + "headscale", "nodes", "expire", + "--identifier", nodeID, + "--disable", + "--output", "json", + }) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + n := listNodeByID(ct) + if n == nil { + return + } + + // --disable clears the expiry (nil), so the node has no expiry in the + // future — it never expires. A nil expiry deserialises to the Unix + // epoch rather than the zero time, so assert "not in the future" + // rather than IsZero. + assert.False(ct, n.GetExpiry().AsTime().After(time.Now()), + "disabled node should not have a future expiry") + }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second, "Waiting for --disable to clear expiry") +} + +// TestNodeCommandValidation exercises the validation and error permutations of +// the node subcommands and their flags against a single populated server: a +// missing required --identifier, non-existent identifiers, and malformed flag +// values (bad expiry time, bad tag, bad CIDR). One real node is registered so +// the "valid id, bad value" paths reach the server. +func TestNodeCommandValidation(t *testing.T) { + IntegrationSkip(t) + + scenario, headscale := setupCLIScenario(t, "cli-nodeval", []string{"user1"}, 0) + defer scenario.ShutdownAssertNoPanics(t) + + regID := types.MustAuthID().String() + + _, err := headscale.Execute([]string{ + "headscale", "debug", "create-node", + "--name", "valnode", "--user", "user1", "--key", regID, "--output", "json", + }) + require.NoError(t, err) + + var node v1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{"headscale", "auth", "register", "--user", "user1", "--auth-id", regID, "--output", "json"}, + &node, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node registration") + + id := strconv.FormatUint(node.GetId(), 10) + + // wantErr is matched with ErrorContains; an empty wantErr only requires + // that the command fails (used where the exact message is not load-bearing). + tests := []struct { + name string + args []string + wantErr string + }{ + {"delete missing identifier", []string{"nodes", "delete", "--force"}, "identifier"}, + {"delete nonexistent", []string{"nodes", "delete", "--identifier", "99999", "--force"}, "node not found"}, + {"rename missing identifier", []string{"nodes", "rename", "newname"}, "identifier"}, + {"rename nonexistent", []string{"nodes", "rename", "--identifier", "99999", "newname"}, ""}, + {"rename too long", []string{"nodes", "rename", "--identifier", id, strings.Repeat("t", 64)}, "too long"}, + {"expire missing identifier", []string{"nodes", "expire"}, "identifier"}, + {"expire nonexistent", []string{"nodes", "expire", "--identifier", "99999"}, ""}, + {"expire invalid time", []string{"nodes", "expire", "--identifier", id, "--expiry", "not-a-time"}, "parsing expiry"}, + {"tag missing identifier", []string{"nodes", "tag", "--tags", "tag:x"}, "identifier"}, + {"tag empty", []string{"nodes", "tag", "--identifier", id}, "cannot remove all tags"}, + {"tag invalid format", []string{"nodes", "tag", "--identifier", id, "--tags", "notatag"}, "tag must start"}, + {"tag unpermitted", []string{"nodes", "tag", "--identifier", id, "--tags", "tag:undefined"}, "invalid or not permitted"}, + {"approve missing identifier", []string{"nodes", "approve-routes", "--routes", "10.0.0.0/24"}, "identifier"}, + {"approve nonexistent", []string{"nodes", "approve-routes", "--identifier", "99999", "--routes", "10.0.0.0/24"}, ""}, + {"approve invalid cidr", []string{"nodes", "approve-routes", "--identifier", id, "--routes", "notacidr"}, "parsing route"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := headscale.Execute(append([]string{"headscale"}, tt.args...)) + if tt.wantErr == "" { + require.Error(t, err) + + return + } + + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +// TestNodeTagCommand exercises `headscale nodes tag` against a live node: it +// sets tags (converting a user-owned node into a tagged node) and validates the +// error paths (no tags, invalid tag format). +func TestNodeTagCommand(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + Users: []string{"user1"}, + NodesPerUser: 1, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + // tag:test1 / tag:test2 must be defined in the policy and owned by user1, + // otherwise the admin `nodes tag` command is rejected as "invalid or not + // permitted" — tags must exist in tagOwners. + policy := &policyv2.Policy{ + ACLs: []policyv2.ACL{ + { + Action: "accept", + Protocol: "tcp", + Sources: []policyv2.Alias{wildcard()}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(wildcard(), tailcfg.PortRangeAny), + }, + }, + }, + TagOwners: policyv2.TagOwners{ + policyv2.Tag("tag:test1"): policyv2.Owners{usernameOwner("user1@")}, + policyv2.Tag("tag:test2"): policyv2.Owners{usernameOwner("user1@")}, + }, + } + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("cli-nodetag"), + hsic.WithACLPolicy(policy), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + require.NoError(t, scenario.WaitForTailscaleSync()) + + var nodeID uint64 + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(ct, err) + assert.Len(ct, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + assert.Equal(ct, "user1", nodes[0].GetUser().GetName(), "node should start user-owned") + } + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) + + idStr := strconv.FormatUint(nodeID, 10) + + // Set two tags. The command response is round-tripped (transport check); + // the resulting tag state is asserted via the authoritative list read-back + // below rather than the immediate mutation response. + tagged := assertJSONRoundtrip[*v1.Node](t, headscale, []string{ + "headscale", "nodes", "tag", + "--identifier", idStr, + "--tags", "tag:test1,tag:test2", + "--output", "json", + }) + assert.Equal(t, nodeID, tagged.GetId(), "tag response should be for the same node") + + // The node is now a tagged node, presented as the tagged-devices user. + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(ct, err) + assert.Len(ct, nodes, 1) + + if len(nodes) == 1 { + assert.Equal(ct, "tagged-devices", nodes[0].GetUser().GetName(), "tagged node shows as tagged-devices") + assert.ElementsMatch(ct, []string{"tag:test1", "tag:test2"}, nodes[0].GetTags()) + } + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) + + // Error: tagged nodes must keep at least one tag, so an empty tag set is + // rejected. + _, err = headscale.Execute([]string{ + "headscale", "nodes", "tag", + "--identifier", idStr, + }) + require.ErrorContains(t, err, "cannot remove all tags") + + // Error: malformed tag (missing the "tag:" prefix). + _, err = headscale.Execute([]string{ + "headscale", "nodes", "tag", + "--identifier", idStr, + "--tags", "not-a-valid-tag", + }) + require.Error(t, err, "an invalid tag format must be rejected") +} + +// TestNodeRouteCommands exercises `nodes approve-routes` and `nodes list-routes` +// end-to-end against a client that advertises a subnet route. +func TestNodeRouteCommands(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + Users: []string{"user1"}, + NodesPerUser: 1, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{tsic.WithAcceptRoutes()}, + hsic.WithTestName("cli-noderoutes"), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + require.Len(t, allClients, 1) + + require.NoError(t, scenario.WaitForTailscaleSync()) + + const route = "10.33.0.0/24" + + // Advertise a subnet route from the client. + _, _, err = allClients[0].Execute([]string{"tailscale", "set", "--advertise-routes=" + route}) + require.NoError(t, err) + + require.NoError(t, scenario.WaitForTailscaleSync()) + + // The advertised route should show up as available (but not approved). + var nodeID uint64 + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + var nodes []v1.Node + + err := executeAndUnmarshal(headscale, + []string{"headscale", "nodes", "list-routes", "--output", "json"}, + &nodes, + ) + assert.NoError(ct, err) + assert.Len(ct, nodes, 1, "list-routes should show the route-advertising node") + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + assert.Contains(ct, nodes[0].GetAvailableRoutes(), route) + assert.Empty(ct, nodes[0].GetApprovedRoutes()) + } + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) + + idStr := strconv.FormatUint(nodeID, 10) + + // Approve the route via the CLI. + approved := assertJSONRoundtrip[*v1.Node](t, headscale, []string{ + "headscale", "nodes", "approve-routes", + "--identifier", idStr, + "--routes=" + route, + "--output", "json", + }) + assert.Contains(t, approved.GetApprovedRoutes(), route) + + // list-routes filtered by the identifier should report the approved route + // as a primary subnet route. + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + var nodes []v1.Node + + err := executeAndUnmarshal(headscale, + []string{"headscale", "nodes", "list-routes", "--identifier", idStr, "--output", "json"}, + &nodes, + ) + assert.NoError(ct, err) + assert.Len(ct, nodes, 1) + + if len(nodes) == 1 { + assert.Contains(ct, nodes[0].GetApprovedRoutes(), route) + assert.Contains(ct, nodes[0].GetSubnetRoutes(), route) + } + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) + + // Remove all approved routes by passing an empty --routes value. + cleared := assertJSONRoundtrip[*v1.Node](t, headscale, []string{ + "headscale", "nodes", "approve-routes", + "--identifier", idStr, + "--routes=", + "--output", "json", + }) + assert.Empty(t, cleared.GetApprovedRoutes(), "approved routes should be cleared") +} + +// TestNodeBackfillIPsCommand exercises `nodes backfillips` against live nodes. +// With both IPv4 and IPv6 prefixes configured (the integration default) the +// command is a no-op for IP assignment, but it must still run cleanly and the +// nodes must retain their addresses. +func TestNodeBackfillIPsCommand(t *testing.T) { + IntegrationSkip(t) + + scenario, headscale := setupCLIScenario(t, "cli-backfillips", []string{"user1"}, 2) + defer scenario.ShutdownAssertNoPanics(t) + + require.NoError(t, scenario.WaitForTailscaleSync()) + + var before []*v1.Node + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + var err error + + before, err = headscale.ListNodes() + assert.NoError(ct, err) + assert.Len(ct, before, 2) + + for _, n := range before { + assert.NotEmpty(ct, n.GetIpAddresses(), "node should have IPs before backfill") + } + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) + + out, err := headscale.Execute([]string{"headscale", "nodes", "backfillips", "--force"}) + require.NoError(t, err) + assert.Contains(t, out, "backfilled") + + // Nodes must still have their IP addresses afterwards. + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + after, err := headscale.ListNodes() + assert.NoError(ct, err) + assert.Len(ct, after, 2) + + for _, n := range after { + assert.NotEmpty(ct, n.GetIpAddresses(), "node should still have IPs after backfill") + } + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) +} diff --git a/integration/cli_policy_test.go b/integration/cli_policy_test.go index 9773b895..7c8eb0bf 100644 --- a/integration/cli_policy_test.go +++ b/integration/cli_policy_test.go @@ -3,11 +3,14 @@ package integration import ( "encoding/json" "testing" + "time" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/integrationutil" "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "tailscale.com/tailcfg" ) @@ -278,3 +281,164 @@ func TestSSHTestsRejectFailingPolicy(t *testing.T) { require.JSONEq(t, string(goodBytes), stdoutAfter, "stored policy must be unchanged after a rejected set") } + +func TestPolicyCommand(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + Users: []string{"user1"}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("cli-policy"), + hsic.WithConfigEnv(map[string]string{ + "HEADSCALE_POLICY_MODE": "database", // test sets/gets policy via CLI + }), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + p := policyv2.Policy{ + ACLs: []policyv2.ACL{ + { + Action: "accept", + Protocol: "tcp", + Sources: []policyv2.Alias{wildcard()}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(wildcard(), tailcfg.PortRangeAny), + }, + }, + }, + TagOwners: policyv2.TagOwners{ + policyv2.Tag("tag:exists"): policyv2.Owners{usernameOwner("user1@")}, + }, + } + + pBytes, _ := json.Marshal(p) //nolint:errchkjson + + policyFilePath := "/etc/headscale/policy.json" + + err = headscale.WriteFile(policyFilePath, pBytes) + require.NoError(t, err) + + // No policy is present at this time. + // Add a new policy from a file. + _, err = headscale.Execute( + []string{ + "headscale", + "policy", + "set", + "-f", + policyFilePath, + }, + ) + + require.NoError(t, err) + + // Get the current policy and check + // if it is the same as the one we set. + var output *policyv2.Policy + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "policy", + "get", + "--output", + "json", + }, + &output, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for policy get command") + + assert.Len(t, output.TagOwners, 1) + assert.Len(t, output.ACLs, 1) +} + +func TestPolicyBrokenConfigCommand(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{"user1"}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("cli-policybad"), + hsic.WithConfigEnv(map[string]string{ + "HEADSCALE_POLICY_MODE": "database", // test sets invalid policy via CLI + }), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + p := policyv2.Policy{ + ACLs: []policyv2.ACL{ + { + // This is an unknown action, so it will return an error + // and the config will not be applied. + Action: "unknown-action", + Protocol: "tcp", + Sources: []policyv2.Alias{wildcard()}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(wildcard(), tailcfg.PortRangeAny), + }, + }, + }, + TagOwners: policyv2.TagOwners{ + policyv2.Tag("tag:exists"): policyv2.Owners{usernameOwner("user1@")}, + }, + } + + pBytes, _ := json.Marshal(p) //nolint:errchkjson + + policyFilePath := "/etc/headscale/policy.json" + + err = headscale.WriteFile(policyFilePath, pBytes) + require.NoError(t, err) + + // No policy is present at this time. + // Add a new policy from a file. + _, err = headscale.Execute( + []string{ + "headscale", + "policy", + "set", + "-f", + policyFilePath, + }, + ) + require.ErrorContains(t, err, `action="unknown-action" is not supported: invalid ACL action`) + + // The new policy was invalid, the old one should still be in place, which + // is none. + _, err = headscale.Execute( + []string{ + "headscale", + "policy", + "get", + "--output", + "json", + }, + ) + assert.ErrorContains(t, err, "acl policy not found") +} diff --git a/integration/cli_preauthkeys_test.go b/integration/cli_preauthkeys_test.go new file mode 100644 index 00000000..63fb43cd --- /dev/null +++ b/integration/cli_preauthkeys_test.go @@ -0,0 +1,397 @@ +package integration + +import ( + "strconv" + "testing" + "time" + + v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/integrationutil" + "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPreAuthKeyCommand(t *testing.T) { + IntegrationSkip(t) + + user := "preauthkeyspace" + count := 3 + + spec := ScenarioSpec{ + Users: []string{user}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("clipak")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + keys := make([]*v1.PreAuthKey, count) + + require.NoError(t, err) + + for index := range count { + var preAuthKey v1.PreAuthKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err := executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "--user", + "1", + "create", + "--reusable", + "--expiration", + "24h", + "--output", + "json", + "--tags", + "tag:test1,tag:test2", + }, + &preAuthKey, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth key creation") + + keys[index] = &preAuthKey + } + + assert.Len(t, keys, 3) + + var listedPreAuthKeys []v1.PreAuthKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "list", + "--output", + "json", + }, + &listedPreAuthKeys, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list") + + // There is one key created by [Scenario.CreateHeadscaleEnv] + assert.Len(t, listedPreAuthKeys, 4) + + assert.Equal( + t, + []uint64{keys[0].GetId(), keys[1].GetId(), keys[2].GetId()}, + []uint64{ + listedPreAuthKeys[1].GetId(), + listedPreAuthKeys[2].GetId(), + listedPreAuthKeys[3].GetId(), + }, + ) + + // New keys show prefix after listing, so check the created keys instead + assert.NotEmpty(t, keys[0].GetKey()) + assert.NotEmpty(t, keys[1].GetKey()) + assert.NotEmpty(t, keys[2].GetKey()) + + assert.True(t, listedPreAuthKeys[1].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedPreAuthKeys[2].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedPreAuthKeys[3].GetExpiration().AsTime().After(time.Now())) + + assert.True( + t, + listedPreAuthKeys[1].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + ) + assert.True( + t, + listedPreAuthKeys[2].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + ) + assert.True( + t, + listedPreAuthKeys[3].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + ) + + for index := range listedPreAuthKeys { + if index == 0 { + continue + } + + assert.Equal( + t, + []string{"tag:test1", "tag:test2"}, + listedPreAuthKeys[index].GetAclTags(), + ) + } + + // Test key expiry + _, err = headscale.Execute( + []string{ + "headscale", + "preauthkeys", + "expire", + "--id", + strconv.FormatUint(keys[0].GetId(), 10), + }, + ) + require.NoError(t, err) + + var listedPreAuthKeysAfterExpire []v1.PreAuthKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "list", + "--output", + "json", + }, + &listedPreAuthKeysAfterExpire, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list after expire") + + assert.True(t, listedPreAuthKeysAfterExpire[1].GetExpiration().AsTime().Before(time.Now())) + assert.True(t, listedPreAuthKeysAfterExpire[2].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedPreAuthKeysAfterExpire[3].GetExpiration().AsTime().After(time.Now())) +} + +func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) { + IntegrationSkip(t) + + user := "pre-auth-key-without-exp-user" + spec := ScenarioSpec{ + Users: []string{user}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("clipaknaexp")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + var preAuthKey v1.PreAuthKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "--user", + "1", + "create", + "--reusable", + "--output", + "json", + }, + &preAuthKey, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth key creation without expiry") + + var listedPreAuthKeys []v1.PreAuthKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "list", + "--output", + "json", + }, + &listedPreAuthKeys, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list") + + // There is one key created by [Scenario.CreateHeadscaleEnv] + assert.Len(t, listedPreAuthKeys, 2) + + assert.True(t, listedPreAuthKeys[1].GetExpiration().AsTime().After(time.Now())) + assert.True( + t, + listedPreAuthKeys[1].GetExpiration().AsTime().Before(time.Now().Add(time.Minute*70)), + ) +} + +func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) { + IntegrationSkip(t) + + user := "pre-auth-key-reus-ephm-user" + spec := ScenarioSpec{ + Users: []string{user}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("clipakresueeph")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + var preAuthReusableKey v1.PreAuthKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "--user", + "1", + "create", + "--reusable=true", + "--output", + "json", + }, + &preAuthReusableKey, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for reusable preauth key creation") + + var preAuthEphemeralKey v1.PreAuthKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "--user", + "1", + "create", + "--ephemeral=true", + "--output", + "json", + }, + &preAuthEphemeralKey, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for ephemeral preauth key creation") + + assert.True(t, preAuthEphemeralKey.GetEphemeral()) + assert.False(t, preAuthEphemeralKey.GetReusable()) + + var listedPreAuthKeys []v1.PreAuthKey + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "list", + "--output", + "json", + }, + &listedPreAuthKeys, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list after reusable/ephemeral creation") + + // There is one key created by [Scenario.CreateHeadscaleEnv] + assert.Len(t, listedPreAuthKeys, 3) +} + +// TestPreAuthKeyDeleteCommand covers `preauthkeys delete --id` (the only +// pre-auth-key subcommand not otherwise exercised) plus its missing-flag +// validation. +func TestPreAuthKeyDeleteCommand(t *testing.T) { + IntegrationSkip(t) + + scenario, headscale := setupCLIScenario(t, "cli-pakdelete", []string{"user1"}, 0) + defer scenario.ShutdownAssertNoPanics(t) + + // Create a key to delete. + created := assertJSONRoundtrip[*v1.PreAuthKey](t, headscale, []string{ + "headscale", + "preauthkeys", + "--user", "1", + "create", + "--reusable", + "--output", "json", + }) + require.NotZero(t, created.GetId()) + + // delete with no --id must be rejected. + _, err := headscale.Execute([]string{"headscale", "preauthkeys", "delete"}) + require.ErrorContains(t, err, "missing --id parameter") + + // delete the created key by id. + _, err = headscale.Execute([]string{ + "headscale", "preauthkeys", "delete", + "--id", strconv.FormatUint(created.GetId(), 10), + }) + require.NoError(t, err) + + // The deleted key must be gone from the list. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + var listed []v1.PreAuthKey + + err := executeAndUnmarshal(headscale, + []string{"headscale", "preauthkeys", "list", "--output", "json"}, + &listed, + ) + assert.NoError(c, err) + + for i := range listed { + assert.NotEqual(c, created.GetId(), listed[i].GetId(), "deleted key should not be listed") + } + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth key list after delete") +} + +// TestPreAuthKeyCommandValidation covers the validation permutations of the +// pre-auth-key subcommands: create with a malformed tag or a non-existent user, +// the required --id on expire/delete, and deleting a non-existent key. +func TestPreAuthKeyCommandValidation(t *testing.T) { + IntegrationSkip(t) + + scenario, headscale := setupCLIScenario(t, "cli-pakval", []string{"user1"}, 0) + defer scenario.ShutdownAssertNoPanics(t) + + tests := []struct { + name string + args []string + wantErr string + }{ + {name: "create malformed tag", args: []string{"preauthkeys", "--user", "1", "create", "--tags", "notatag", "--output", "json"}}, + {name: "create nonexistent user", args: []string{"preauthkeys", "--user", "99999", "create", "--output", "json"}}, + {name: "expire missing id", args: []string{"preauthkeys", "expire"}, wantErr: "missing --id parameter"}, + {name: "delete missing id", args: []string{"preauthkeys", "delete"}, wantErr: "missing --id parameter"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := headscale.Execute(append([]string{"headscale"}, tt.args...)) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + + return + } + + require.Error(t, err) + }) + } +} diff --git a/integration/cli_server_test.go b/integration/cli_server_test.go new file mode 100644 index 00000000..51842b15 --- /dev/null +++ b/integration/cli_server_test.go @@ -0,0 +1,47 @@ +package integration + +import ( + "testing" + + v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestServerInfoCommands covers the standalone server/info commands that do not +// fit the resource CRUD groups: `health`, `version` and +// `generate private-key`. They are exercised against a populated server (one +// user with a node) so `health` reports a real, connected database. +func TestServerInfoCommands(t *testing.T) { + IntegrationSkip(t) + + scenario, headscale := setupCLIScenario(t, "cli-serverinfo", []string{"user1"}, 1) + defer scenario.ShutdownAssertNoPanics(t) + + t.Run("health", func(t *testing.T) { + health := assertJSONRoundtrip[*v1.HealthResponse](t, headscale, []string{ + "headscale", "health", "--output", "json", + }) + assert.True(t, health.GetDatabaseConnectivity(), "database should be reachable") + }) + + t.Run("version", func(t *testing.T) { + info := assertJSONRoundtrip[types.VersionInfo](t, headscale, []string{ + "headscale", "version", "--output", "json", + }) + assert.NotEmpty(t, info.Version, "version string should be populated") + assert.NotEmpty(t, info.Go.Version, "go version should be populated") + }) + + t.Run("generate-private-key", func(t *testing.T) { + key := assertJSONRoundtrip[map[string]string](t, headscale, []string{ + "headscale", "generate", "private-key", "--output", "json", + }) + + priv, ok := key["private_key"] + require.True(t, ok, "output should contain a private_key field") + assert.NotEmpty(t, priv, "generated private key should not be empty") + assert.Contains(t, priv, "privkey:", "machine private key should carry the privkey: prefix") + }) +} diff --git a/integration/cli_test.go b/integration/cli_test.go index d04bd02d..86d3f4b5 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -4,25 +4,24 @@ import ( "cmp" "encoding/json" "fmt" - "slices" - "strconv" - "strings" "testing" - "time" - tcmp "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" - policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" - "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/integration/hsic" - "github.com/juanfont/headscale/integration/integrationutil" "github.com/juanfont/headscale/integration/tsic" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "tailscale.com/tailcfg" ) +// This file holds the shared helpers used by the per-command CLI integration +// test files (cli_users_test.go, cli_nodes_test.go, cli_apikeys_test.go, +// cli_preauthkeys_test.go, cli_auth_test.go, cli_server_test.go and +// cli_policy_test.go). The tests themselves live in those files, grouped by +// the command they exercise. +// +// The whole point of the CLI test suite is to guard the transport: every +// command is invoked with `--output json` and the result is unmarshalled into +// the matching gen/go/headscale/v1 Go type, so a change to the gRPC handlers, +// proto definitions or output encoders that breaks a command is caught here. + func executeAndUnmarshal[T any](headscale ControlServer, command []string, result T) error { str, err := headscale.Execute(command) if err != nil { @@ -37,6 +36,32 @@ func executeAndUnmarshal[T any](headscale ControlServer, command []string, resul return nil } +// assertJSONRoundtrip executes command (which must include `--output json`), +// decodes the stdout into T, then marshals T back to JSON and re-decodes it, +// asserting the serialisation is stable. This is the transport contract guard: +// if the underlying v1 type drifts in a way that loses data, the round-trip +// breaks. The decoded value is returned so callers can assert on real fields. +func assertJSONRoundtrip[T any](t require.TestingT, headscale ControlServer, command []string) T { + var first T + + err := executeAndUnmarshal(headscale, command, &first) + require.NoError(t, err, "decoding CLI json output") + + firstBytes, err := json.Marshal(first) + require.NoError(t, err, "re-marshalling decoded value") + + var second T + + require.NoError(t, json.Unmarshal(firstBytes, &second), "re-decoding marshalled value") + + secondBytes, err := json.Marshal(second) + require.NoError(t, err, "re-marshalling round-tripped value") + + require.JSONEq(t, string(firstBytes), string(secondBytes), "json round-trip should be stable") + + return second +} + // Interface ensuring that we can sort structs from gRPC that // have an ID field. type GRPCSortable interface { @@ -47,1734 +72,28 @@ func sortWithID[T GRPCSortable](a, b T) int { return cmp.Compare(a.GetId(), b.GetId()) } -func TestUserCommand(t *testing.T) { - IntegrationSkip(t) +// setupCLIScenario boots a scenario with the given users and nodes-per-user, +// creates the headscale environment and returns the running scenario and its +// control server. It removes the repeated NewScenario/CreateHeadscaleEnv/ +// Headscale boilerplate shared by the CLI tests. Callers still defer +// scenario.ShutdownAssertNoPanics(t) themselves so the cleanup is visible at +// the call site. +func setupCLIScenario(t *testing.T, testName string, users []string, nodesPerUser int) (*Scenario, ControlServer) { + t.Helper() spec := ScenarioSpec{ - Users: []string{"user1", "user2"}, + Users: users, + NodesPerUser: nodesPerUser, } scenario, err := NewScenario(spec) - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-user")) + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName(testName)) require.NoError(t, err) headscale, err := scenario.Headscale() require.NoError(t, err) - var ( - listUsers []*v1.User - result []string - ) - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - err := executeAndUnmarshal(headscale, - []string{ - "headscale", - "users", - "list", - "--output", - "json", - }, - &listUsers, - ) - assert.NoError(ct, err) - - slices.SortFunc(listUsers, sortWithID) - result = []string{listUsers[0].GetName(), listUsers[1].GetName()} - - assert.Equal( - ct, - []string{"user1", "user2"}, - result, - "Should have user1 and user2 in users list", - ) - }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) - - _, err = headscale.Execute( - []string{ - "headscale", - "users", - "rename", - "--output=json", - fmt.Sprintf("--identifier=%d", listUsers[1].GetId()), - "--new-name=newname", - }, - ) - require.NoError(t, err) - - var listAfterRenameUsers []*v1.User - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - err := executeAndUnmarshal(headscale, - []string{ - "headscale", - "users", - "list", - "--output", - "json", - }, - &listAfterRenameUsers, - ) - assert.NoError(ct, err) - - slices.SortFunc(listAfterRenameUsers, sortWithID) - result = []string{listAfterRenameUsers[0].GetName(), listAfterRenameUsers[1].GetName()} - - assert.Equal( - ct, - []string{"user1", "newname"}, - result, - "Should have user1 and newname after rename operation", - ) - }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) - - var listByUsername []*v1.User - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal(headscale, - []string{ - "headscale", - "users", - "list", - "--output", - "json", - "--name=user1", - }, - &listByUsername, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user list by username") - - slices.SortFunc(listByUsername, sortWithID) - - want := []*v1.User{ - { - Id: 1, - Name: "user1", - Email: "user1@test.no", - }, - } - - if diff := tcmp.Diff(want, listByUsername, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { - t.Errorf("unexpected users (-want +got):\n%s", diff) - } - - var listByID []*v1.User - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal(headscale, - []string{ - "headscale", - "users", - "list", - "--output", - "json", - "--identifier=1", - }, - &listByID, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user list by ID") - - slices.SortFunc(listByID, sortWithID) - - want = []*v1.User{ - { - Id: 1, - Name: "user1", - Email: "user1@test.no", - }, - } - - if diff := tcmp.Diff(want, listByID, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { - t.Errorf("unexpected users (-want +got):\n%s", diff) - } - - deleteResult, err := headscale.Execute( - []string{ - "headscale", - "users", - "destroy", - "--force", - // Delete "user1" - "--identifier=1", - }, - ) - require.NoError(t, err) - assert.Contains(t, deleteResult, "User destroyed") - - var listAfterIDDelete []*v1.User - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - err := executeAndUnmarshal(headscale, - []string{ - "headscale", - "users", - "list", - "--output", - "json", - }, - &listAfterIDDelete, - ) - assert.NoError(ct, err) - - slices.SortFunc(listAfterIDDelete, sortWithID) - - want := []*v1.User{ - { - Id: 2, - Name: "newname", - Email: "user2@test.no", - }, - } - - if diff := tcmp.Diff(want, listAfterIDDelete, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { - assert.Fail(ct, "unexpected users", "diff (-want +got):\n%s", diff) - } - }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) - - deleteResult, err = headscale.Execute( - []string{ - "headscale", - "users", - "destroy", - "--force", - "--name=newname", - }, - ) - require.NoError(t, err) - assert.Contains(t, deleteResult, "User destroyed") - - var listAfterNameDelete []v1.User - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal(headscale, - []string{ - "headscale", - "users", - "list", - "--output", - "json", - }, - &listAfterNameDelete, - ) - assert.NoError(c, err) - assert.Empty(c, listAfterNameDelete) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user list after name delete") -} - -func TestPreAuthKeyCommand(t *testing.T) { - IntegrationSkip(t) - - user := "preauthkeyspace" - count := 3 - - spec := ScenarioSpec{ - Users: []string{user}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("clipak")) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - keys := make([]*v1.PreAuthKey, count) - - require.NoError(t, err) - - for index := range count { - var preAuthKey v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err := executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "--user", - "1", - "create", - "--reusable", - "--expiration", - "24h", - "--output", - "json", - "--tags", - "tag:test1,tag:test2", - }, - &preAuthKey, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth key creation") - - keys[index] = &preAuthKey - } - - assert.Len(t, keys, 3) - - var listedPreAuthKeys []v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "list", - "--output", - "json", - }, - &listedPreAuthKeys, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list") - - // There is one key created by [Scenario.CreateHeadscaleEnv] - assert.Len(t, listedPreAuthKeys, 4) - - assert.Equal( - t, - []uint64{keys[0].GetId(), keys[1].GetId(), keys[2].GetId()}, - []uint64{ - listedPreAuthKeys[1].GetId(), - listedPreAuthKeys[2].GetId(), - listedPreAuthKeys[3].GetId(), - }, - ) - - // New keys show prefix after listing, so check the created keys instead - assert.NotEmpty(t, keys[0].GetKey()) - assert.NotEmpty(t, keys[1].GetKey()) - assert.NotEmpty(t, keys[2].GetKey()) - - assert.True(t, listedPreAuthKeys[1].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedPreAuthKeys[2].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedPreAuthKeys[3].GetExpiration().AsTime().After(time.Now())) - - assert.True( - t, - listedPreAuthKeys[1].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), - ) - assert.True( - t, - listedPreAuthKeys[2].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), - ) - assert.True( - t, - listedPreAuthKeys[3].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), - ) - - for index := range listedPreAuthKeys { - if index == 0 { - continue - } - - assert.Equal( - t, - []string{"tag:test1", "tag:test2"}, - listedPreAuthKeys[index].GetAclTags(), - ) - } - - // Test key expiry - _, err = headscale.Execute( - []string{ - "headscale", - "preauthkeys", - "expire", - "--id", - strconv.FormatUint(keys[0].GetId(), 10), - }, - ) - require.NoError(t, err) - - var listedPreAuthKeysAfterExpire []v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "list", - "--output", - "json", - }, - &listedPreAuthKeysAfterExpire, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list after expire") - - assert.True(t, listedPreAuthKeysAfterExpire[1].GetExpiration().AsTime().Before(time.Now())) - assert.True(t, listedPreAuthKeysAfterExpire[2].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedPreAuthKeysAfterExpire[3].GetExpiration().AsTime().After(time.Now())) -} - -func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) { - IntegrationSkip(t) - - user := "pre-auth-key-without-exp-user" - spec := ScenarioSpec{ - Users: []string{user}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("clipaknaexp")) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - var preAuthKey v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "--user", - "1", - "create", - "--reusable", - "--output", - "json", - }, - &preAuthKey, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth key creation without expiry") - - var listedPreAuthKeys []v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "list", - "--output", - "json", - }, - &listedPreAuthKeys, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list") - - // There is one key created by [Scenario.CreateHeadscaleEnv] - assert.Len(t, listedPreAuthKeys, 2) - - assert.True(t, listedPreAuthKeys[1].GetExpiration().AsTime().After(time.Now())) - assert.True( - t, - listedPreAuthKeys[1].GetExpiration().AsTime().Before(time.Now().Add(time.Minute*70)), - ) -} - -func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) { - IntegrationSkip(t) - - user := "pre-auth-key-reus-ephm-user" - spec := ScenarioSpec{ - Users: []string{user}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("clipakresueeph")) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - var preAuthReusableKey v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "--user", - "1", - "create", - "--reusable=true", - "--output", - "json", - }, - &preAuthReusableKey, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for reusable preauth key creation") - - var preAuthEphemeralKey v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "--user", - "1", - "create", - "--ephemeral=true", - "--output", - "json", - }, - &preAuthEphemeralKey, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for ephemeral preauth key creation") - - assert.True(t, preAuthEphemeralKey.GetEphemeral()) - assert.False(t, preAuthEphemeralKey.GetReusable()) - - var listedPreAuthKeys []v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "list", - "--output", - "json", - }, - &listedPreAuthKeys, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list after reusable/ephemeral creation") - - // There is one key created by [Scenario.CreateHeadscaleEnv] - assert.Len(t, listedPreAuthKeys, 3) -} - -func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { - IntegrationSkip(t) - - //nolint:goconst // test data, not worth extracting - user1 := "user1" - //nolint:goconst // test data, not worth extracting - user2 := "user2" - - spec := ScenarioSpec{ - NodesPerUser: 1, - Users: []string{user1}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv( - []tsic.Option{}, - hsic.WithTestName("cli-paklogin"), - ) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - u2, err := headscale.CreateUser(user2) - require.NoError(t, err) - - var user2Key v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "--user", - strconv.FormatUint(u2.GetId(), 10), - "create", - "--reusable", - "--expiration", - "24h", - "--output", - "json", - "--tags", - "tag:test1,tag:test2", - }, - &user2Key, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user2 preauth key creation") - - var listNodes []*v1.Node - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - var err error - - listNodes, err = headscale.ListNodes() - assert.NoError(ct, err) - assert.Len(ct, listNodes, 1, "Should have exactly 1 node for user1") - assert.Equal(ct, user1, listNodes[0].GetUser().GetName(), "Node should belong to user1") - }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second) - - allClients, err := scenario.ListTailscaleClients() - requireNoErrListClients(t, err) - - require.Len(t, allClients, 1) - - client := allClients[0] - - // Log out from user1 - err = client.Logout() - require.NoError(t, err) - - err = scenario.WaitForTailscaleLogout() - require.NoError(t, err) - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - status, err := client.Status() - assert.NoError(ct, err) - assert.NotContains(ct, []string{"Starting", "Running"}, status.BackendState, - "Expected node to be logged out, backend state: %s", status.BackendState) - }, integrationutil.StatusReadyTimeout, 2*time.Second) - - err = client.Login(headscale.GetEndpoint(), user2Key.GetKey()) - require.NoError(t, err) - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - status, err := client.Status() - assert.NoError(ct, err) - assert.Equal(ct, "Running", status.BackendState, "Expected node to be logged in, backend state: %s", status.BackendState) - // With tags-as-identity model, tagged nodes show as [types.TaggedDevices] user (2147455555) - // The PreAuthKey was created with tags, so the node is tagged - assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user") - }, integrationutil.StatusReadyTimeout, 2*time.Second) - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - var err error - - listNodes, err = headscale.ListNodes() - assert.NoError(ct, err) - assert.Len(ct, listNodes, 2, "Should have 2 nodes after re-login") - assert.Equal(ct, user1, listNodes[0].GetUser().GetName(), "First node should belong to user1") - // Second node is tagged (created with tagged PreAuthKey), so it shows as "tagged-devices" - assert.Equal(ct, "tagged-devices", listNodes[1].GetUser().GetName(), "Second node should be tagged-devices") - }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) -} - -func TestTaggedNodesCLIOutput(t *testing.T) { - IntegrationSkip(t) - - user1 := "user1" - user2 := "user2" - - spec := ScenarioSpec{ - NodesPerUser: 1, - Users: []string{user1}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv( - []tsic.Option{}, - hsic.WithTestName("tagcli"), - ) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - u2, err := headscale.CreateUser(user2) - require.NoError(t, err) - - var user2Key v1.PreAuthKey - - // Create a tagged PreAuthKey for user2 - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "--user", - strconv.FormatUint(u2.GetId(), 10), - "create", - "--reusable", - "--expiration", - "24h", - "--output", - "json", - "--tags", - "tag:test1,tag:test2", - }, - &user2Key, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user2 tagged preauth key creation") - - allClients, err := scenario.ListTailscaleClients() - requireNoErrListClients(t, err) - - require.Len(t, allClients, 1) - - client := allClients[0] - - // Log out from user1 - err = client.Logout() - require.NoError(t, err) - - err = scenario.WaitForTailscaleLogout() - require.NoError(t, err) - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - status, err := client.Status() - assert.NoError(ct, err) - assert.NotContains(ct, []string{"Starting", "Running"}, status.BackendState, - "Expected node to be logged out, backend state: %s", status.BackendState) - }, integrationutil.StatusReadyTimeout, 2*time.Second) - - // Log in with the tagged PreAuthKey (from user2, with tags) - err = client.Login(headscale.GetEndpoint(), user2Key.GetKey()) - require.NoError(t, err) - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - status, err := client.Status() - assert.NoError(ct, err) - assert.Equal(ct, "Running", status.BackendState, "Expected node to be logged in, backend state: %s", status.BackendState) - // With tags-as-identity model, tagged nodes show as [types.TaggedDevices] user (2147455555) - assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user") - }, integrationutil.StatusReadyTimeout, 2*time.Second) - - // Wait for the second node to appear - var listNodes []*v1.Node - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - var err error - - listNodes, err = headscale.ListNodes() - assert.NoError(ct, err) - assert.Len(ct, listNodes, 2, "Should have 2 nodes after re-login with tagged key") - assert.Equal(ct, user1, listNodes[0].GetUser().GetName(), "First node should belong to user1") - assert.Equal(ct, "tagged-devices", listNodes[1].GetUser().GetName(), "Second node should be tagged-devices") - }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) - - // Test: tailscale status output should show "tagged-devices" not "userid:2147455555" - // This is the fix for issue #2970 - the Tailscale client should display user-friendly names - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - stdout, stderr, err := client.Execute([]string{"tailscale", "status"}) - assert.NoError(ct, err, "tailscale status command should succeed, stderr: %s", stderr) - - t.Logf("Tailscale status output:\n%s", stdout) - - // The output should contain "tagged-devices" for tagged nodes - assert.Contains(ct, stdout, "tagged-devices", "Tailscale status should show 'tagged-devices' for tagged nodes") - - // The output should NOT show the raw numeric userid to the user - assert.NotContains(ct, stdout, "userid:2147455555", "Tailscale status should not show numeric userid for tagged nodes") - }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) -} - -func TestApiKeyCommand(t *testing.T) { - IntegrationSkip(t) - - count := 5 - - spec := ScenarioSpec{ - Users: []string{"user1", "user2"}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-apikey")) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - keys := make([]string, count) - - for idx := range count { - apiResult, err := headscale.Execute( - []string{ - "headscale", - "apikeys", - "create", - "--expiration", - "24h", - "--output", - "json", - }, - ) - require.NoError(t, err) - assert.NotEmpty(t, apiResult) - - keys[idx] = apiResult - } - - assert.Len(t, keys, 5) - - var listedAPIKeys []v1.ApiKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal(headscale, - []string{ - "headscale", - "apikeys", - "list", - "--output", - "json", - }, - &listedAPIKeys, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list") - - assert.Len(t, listedAPIKeys, 5) - - assert.Equal(t, uint64(1), listedAPIKeys[0].GetId()) - assert.Equal(t, uint64(2), listedAPIKeys[1].GetId()) - assert.Equal(t, uint64(3), listedAPIKeys[2].GetId()) - assert.Equal(t, uint64(4), listedAPIKeys[3].GetId()) - assert.Equal(t, uint64(5), listedAPIKeys[4].GetId()) - - assert.NotEmpty(t, listedAPIKeys[0].GetPrefix()) - assert.NotEmpty(t, listedAPIKeys[1].GetPrefix()) - assert.NotEmpty(t, listedAPIKeys[2].GetPrefix()) - assert.NotEmpty(t, listedAPIKeys[3].GetPrefix()) - assert.NotEmpty(t, listedAPIKeys[4].GetPrefix()) - - assert.True(t, listedAPIKeys[0].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedAPIKeys[1].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedAPIKeys[2].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedAPIKeys[3].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedAPIKeys[4].GetExpiration().AsTime().After(time.Now())) - - assert.True( - t, - listedAPIKeys[0].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), - ) - assert.True( - t, - listedAPIKeys[1].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), - ) - assert.True( - t, - listedAPIKeys[2].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), - ) - assert.True( - t, - listedAPIKeys[3].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), - ) - assert.True( - t, - listedAPIKeys[4].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), - ) - - expiredPrefixes := make(map[string]bool) - - // Expire three keys - for idx := range 3 { - _, err := headscale.Execute( - []string{ - "headscale", - "apikeys", - "expire", - "--prefix", - listedAPIKeys[idx].GetPrefix(), - }, - ) - require.NoError(t, err) - - expiredPrefixes[listedAPIKeys[idx].GetPrefix()] = true - } - - var listedAfterExpireAPIKeys []v1.ApiKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal(headscale, - []string{ - "headscale", - "apikeys", - "list", - "--output", - "json", - }, - &listedAfterExpireAPIKeys, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list after expire") - - for index := range listedAfterExpireAPIKeys { - if _, ok := expiredPrefixes[listedAfterExpireAPIKeys[index].GetPrefix()]; ok { - // Expired - assert.True( - t, - listedAfterExpireAPIKeys[index].GetExpiration().AsTime().Before(time.Now()), - ) - } else { - // Not expired - assert.False( - t, - listedAfterExpireAPIKeys[index].GetExpiration().AsTime().Before(time.Now()), - ) - } - } - - _, err = headscale.Execute( - []string{ - "headscale", - "apikeys", - "delete", - "--prefix", - listedAPIKeys[0].GetPrefix(), - }) - require.NoError(t, err) - - var listedAPIKeysAfterDelete []v1.ApiKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal(headscale, - []string{ - "headscale", - "apikeys", - "list", - "--output", - "json", - }, - &listedAPIKeysAfterDelete, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list after delete") - - assert.Len(t, listedAPIKeysAfterDelete, 4) - - // Test expire by ID (using key at index 0) - _, err = headscale.Execute( - []string{ - "headscale", - "apikeys", - "expire", - "--id", - strconv.FormatUint(listedAPIKeysAfterDelete[0].GetId(), 10), - }) - require.NoError(t, err) - - var listedAPIKeysAfterExpireByID []v1.ApiKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal(headscale, - []string{ - "headscale", - "apikeys", - "list", - "--output", - "json", - }, - &listedAPIKeysAfterExpireByID, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list after expire by ID") - - // Verify the key was expired - for idx := range listedAPIKeysAfterExpireByID { - if listedAPIKeysAfterExpireByID[idx].GetId() == listedAPIKeysAfterDelete[0].GetId() { - assert.True(t, listedAPIKeysAfterExpireByID[idx].GetExpiration().AsTime().Before(time.Now()), - "Key expired by ID should have expiration in the past") - } - } - - // Test delete by ID (using key at index 1) - deletedKeyID := listedAPIKeysAfterExpireByID[1].GetId() - _, err = headscale.Execute( - []string{ - "headscale", - "apikeys", - "delete", - "--id", - strconv.FormatUint(deletedKeyID, 10), - }) - require.NoError(t, err) - - var listedAPIKeysAfterDeleteByID []v1.ApiKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal(headscale, - []string{ - "headscale", - "apikeys", - "list", - "--output", - "json", - }, - &listedAPIKeysAfterDeleteByID, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list after delete by ID") - - assert.Len(t, listedAPIKeysAfterDeleteByID, 3) - - // Verify the specific key was deleted - for idx := range listedAPIKeysAfterDeleteByID { - assert.NotEqual(t, deletedKeyID, listedAPIKeysAfterDeleteByID[idx].GetId(), - "Deleted key should not be present in the list") - } -} - -func TestNodeCommand(t *testing.T) { - IntegrationSkip(t) - - spec := ScenarioSpec{ - Users: []string{"node-user", "other-user"}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-node")) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - regIDs := []string{ - types.MustAuthID().String(), - types.MustAuthID().String(), - types.MustAuthID().String(), - types.MustAuthID().String(), - types.MustAuthID().String(), - } - nodes := make([]*v1.Node, len(regIDs)) - - require.NoError(t, err) - - for index, regID := range regIDs { - _, err := headscale.Execute( - []string{ - "headscale", - "debug", - "create-node", - "--name", - fmt.Sprintf("node-%d", index+1), - "--user", - "node-user", - "--key", - regID, - "--output", - "json", - }, - ) - require.NoError(t, err) - - var node v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "auth", - "register", - "--user", - "node-user", - "--auth-id", - regID, - "--output", - "json", - }, - &node, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node registration") - - nodes[index] = &node - } - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - assert.Len(ct, nodes, len(regIDs), "Should have correct number of nodes after CLI operations") - }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second) - - // Test list all nodes after added seconds - var listAll []v1.Node - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - err := executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "list", - "--output", - "json", - }, - &listAll, - ) - assert.NoError(ct, err) - assert.Len(ct, listAll, len(regIDs), "Should list all nodes after CLI operations") - }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) - - assert.Equal(t, uint64(1), listAll[0].GetId()) - assert.Equal(t, uint64(2), listAll[1].GetId()) - assert.Equal(t, uint64(3), listAll[2].GetId()) - assert.Equal(t, uint64(4), listAll[3].GetId()) - assert.Equal(t, uint64(5), listAll[4].GetId()) - - assert.Equal(t, "node-1", listAll[0].GetName()) - assert.Equal(t, "node-2", listAll[1].GetName()) - assert.Equal(t, "node-3", listAll[2].GetName()) - assert.Equal(t, "node-4", listAll[3].GetName()) - assert.Equal(t, "node-5", listAll[4].GetName()) - - otherUserRegIDs := []string{ - types.MustAuthID().String(), - types.MustAuthID().String(), - } - otherUserMachines := make([]*v1.Node, len(otherUserRegIDs)) - - require.NoError(t, err) - - for index, regID := range otherUserRegIDs { - _, err := headscale.Execute( - []string{ - "headscale", - "debug", - "create-node", - "--name", - fmt.Sprintf("otheruser-node-%d", index+1), - "--user", - "other-user", - "--key", - regID, - "--output", - "json", - }, - ) - require.NoError(t, err) - - var node v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "auth", - "register", - "--user", - "other-user", - "--auth-id", - regID, - "--output", - "json", - }, - &node, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for other-user node registration") - - otherUserMachines[index] = &node - } - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - assert.Len(ct, otherUserMachines, len(otherUserRegIDs), "Should have correct number of otherUser machines after CLI operations") - }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second) - - // Test list all nodes after added otherUser - var listAllWithotherUser []v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "list", - "--output", - "json", - }, - &listAllWithotherUser, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list after adding other-user nodes") - - // All nodes, nodes + otherUser - assert.Len(t, listAllWithotherUser, 7) - - assert.Equal(t, uint64(6), listAllWithotherUser[5].GetId()) - assert.Equal(t, uint64(7), listAllWithotherUser[6].GetId()) - - assert.Equal(t, "otheruser-node-1", listAllWithotherUser[5].GetName()) - assert.Equal(t, "otheruser-node-2", listAllWithotherUser[6].GetName()) - - // Test list all nodes after added otherUser - var listOnlyotherUserMachineUser []v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "list", - "--user", - "other-user", - "--output", - "json", - }, - &listOnlyotherUserMachineUser, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list filtered by other-user") - - assert.Len(t, listOnlyotherUserMachineUser, 2) - - assert.Equal(t, uint64(6), listOnlyotherUserMachineUser[0].GetId()) - assert.Equal(t, uint64(7), listOnlyotherUserMachineUser[1].GetId()) - - assert.Equal( - t, - "otheruser-node-1", - listOnlyotherUserMachineUser[0].GetName(), - ) - assert.Equal( - t, - "otheruser-node-2", - listOnlyotherUserMachineUser[1].GetName(), - ) - - // Delete a nodes - _, err = headscale.Execute( - []string{ - "headscale", - "nodes", - "delete", - "--identifier", - // Delete the last added machine - "4", - "--output", - "json", - "--force", - }, - ) - require.NoError(t, err) - - // Test: list main user after node is deleted - var listOnlyMachineUserAfterDelete []v1.Node - - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - err := executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "list", - "--user", - "node-user", - "--output", - "json", - }, - &listOnlyMachineUserAfterDelete, - ) - assert.NoError(ct, err) - assert.Len(ct, listOnlyMachineUserAfterDelete, 4, "Should have 4 nodes for node-user after deletion") - }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) -} - -func TestNodeExpireCommand(t *testing.T) { - IntegrationSkip(t) - - spec := ScenarioSpec{ - Users: []string{"node-expire-user"}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-nodeexpire")) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - regIDs := []string{ - types.MustAuthID().String(), - types.MustAuthID().String(), - types.MustAuthID().String(), - types.MustAuthID().String(), - types.MustAuthID().String(), - } - nodes := make([]*v1.Node, len(regIDs)) - - for index, regID := range regIDs { - _, err := headscale.Execute( - []string{ - "headscale", - "debug", - "create-node", - "--name", - fmt.Sprintf("node-%d", index+1), - "--user", - "node-expire-user", - "--key", - regID, - "--output", - "json", - }, - ) - require.NoError(t, err) - - var node v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "auth", - "register", - "--user", - "node-expire-user", - "--auth-id", - regID, - "--output", - "json", - }, - &node, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node-expire-user node registration") - - nodes[index] = &node - } - - assert.Len(t, nodes, len(regIDs)) - - var listAll []v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "list", - "--output", - "json", - }, - &listAll, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list in expire test") - - assert.Len(t, listAll, 5) - - // With node.expiry defaulting to 0, non-tagged nodes have zero expiry - // (never expire unless explicitly expired). - for i := range 5 { - assert.True(t, listAll[i].GetExpiry().AsTime().IsZero(), - "node %d should have zero expiry (no default node.expiry)", i) - } - - for idx := range 3 { - _, err := headscale.Execute( - []string{ - "headscale", - "nodes", - "expire", - "--identifier", - strconv.FormatUint(listAll[idx].GetId(), 10), - }, - ) - require.NoError(t, err) - } - - var listAllAfterExpiry []v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "list", - "--output", - "json", - }, - &listAllAfterExpiry, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list after expiry") - - assert.Len(t, listAllAfterExpiry, 5) - - assert.True(t, listAllAfterExpiry[0].GetExpiry().AsTime().Before(time.Now())) - assert.True(t, listAllAfterExpiry[1].GetExpiry().AsTime().Before(time.Now())) - assert.True(t, listAllAfterExpiry[2].GetExpiry().AsTime().Before(time.Now())) - assert.True(t, listAllAfterExpiry[3].GetExpiry().AsTime().IsZero()) - assert.True(t, listAllAfterExpiry[4].GetExpiry().AsTime().IsZero()) -} - -func TestNodeRenameCommand(t *testing.T) { - IntegrationSkip(t) - - spec := ScenarioSpec{ - Users: []string{"node-rename-command"}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-noderename")) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - regIDs := []string{ - types.MustAuthID().String(), - types.MustAuthID().String(), - types.MustAuthID().String(), - types.MustAuthID().String(), - types.MustAuthID().String(), - } - nodes := make([]*v1.Node, len(regIDs)) - - require.NoError(t, err) - - for index, regID := range regIDs { - _, err := headscale.Execute( - []string{ - "headscale", - "debug", - "create-node", - "--name", - fmt.Sprintf("node-%d", index+1), - "--user", - "node-rename-command", - "--key", - regID, - "--output", - "json", - }, - ) - require.NoError(t, err) - - var node v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "auth", - "register", - "--user", - "node-rename-command", - "--auth-id", - regID, - "--output", - "json", - }, - &node, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node-rename-command node registration") - - nodes[index] = &node - } - - assert.Len(t, nodes, len(regIDs)) - - var listAll []v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "list", - "--output", - "json", - }, - &listAll, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list in rename test") - - assert.Len(t, listAll, 5) - - assert.Contains(t, listAll[0].GetGivenName(), "node-1") - assert.Contains(t, listAll[1].GetGivenName(), "node-2") - assert.Contains(t, listAll[2].GetGivenName(), "node-3") - assert.Contains(t, listAll[3].GetGivenName(), "node-4") - assert.Contains(t, listAll[4].GetGivenName(), "node-5") - - for idx := range 3 { - res, err := headscale.Execute( - []string{ - "headscale", - "nodes", - "rename", - "--identifier", - strconv.FormatUint(listAll[idx].GetId(), 10), - fmt.Sprintf("newnode-%d", idx+1), - }, - ) - require.NoError(t, err) - - assert.Contains(t, res, "Node renamed") - } - - var listAllAfterRename []v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "list", - "--output", - "json", - }, - &listAllAfterRename, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list after rename") - - assert.Len(t, listAllAfterRename, 5) - - assert.Equal(t, "newnode-1", listAllAfterRename[0].GetGivenName()) - assert.Equal(t, "newnode-2", listAllAfterRename[1].GetGivenName()) - assert.Equal(t, "newnode-3", listAllAfterRename[2].GetGivenName()) - assert.Contains(t, listAllAfterRename[3].GetGivenName(), "node-4") - assert.Contains(t, listAllAfterRename[4].GetGivenName(), "node-5") - - // Test failure for too long names - _, err = headscale.Execute( - []string{ - "headscale", - "nodes", - "rename", - "--identifier", - strconv.FormatUint(listAll[4].GetId(), 10), - strings.Repeat("t", 64), - }, - ) - require.ErrorContains(t, err, "is too long, max length is 63 bytes") - - var listAllAfterRenameAttempt []v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "list", - "--output", - "json", - }, - &listAllAfterRenameAttempt, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for nodes list after failed rename attempt") - - assert.Len(t, listAllAfterRenameAttempt, 5) - - assert.Equal(t, "newnode-1", listAllAfterRenameAttempt[0].GetGivenName()) - assert.Equal(t, "newnode-2", listAllAfterRenameAttempt[1].GetGivenName()) - assert.Equal(t, "newnode-3", listAllAfterRenameAttempt[2].GetGivenName()) - assert.Contains(t, listAllAfterRenameAttempt[3].GetGivenName(), "node-4") - assert.Contains(t, listAllAfterRenameAttempt[4].GetGivenName(), "node-5") -} - -func TestPolicyCommand(t *testing.T) { - IntegrationSkip(t) - - spec := ScenarioSpec{ - Users: []string{"user1"}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv( - []tsic.Option{}, - hsic.WithTestName("cli-policy"), - hsic.WithConfigEnv(map[string]string{ - "HEADSCALE_POLICY_MODE": "database", // test sets/gets policy via CLI - }), - ) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - p := policyv2.Policy{ - ACLs: []policyv2.ACL{ - { - Action: "accept", - Protocol: "tcp", - Sources: []policyv2.Alias{wildcard()}, - Destinations: []policyv2.AliasWithPorts{ - aliasWithPorts(wildcard(), tailcfg.PortRangeAny), - }, - }, - }, - TagOwners: policyv2.TagOwners{ - policyv2.Tag("tag:exists"): policyv2.Owners{usernameOwner("user1@")}, - }, - } - - pBytes, _ := json.Marshal(p) //nolint:errchkjson - - policyFilePath := "/etc/headscale/policy.json" - - err = headscale.WriteFile(policyFilePath, pBytes) - require.NoError(t, err) - - // No policy is present at this time. - // Add a new policy from a file. - _, err = headscale.Execute( - []string{ - "headscale", - "policy", - "set", - "-f", - policyFilePath, - }, - ) - - require.NoError(t, err) - - // Get the current policy and check - // if it is the same as the one we set. - var output *policyv2.Policy - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "policy", - "get", - "--output", - "json", - }, - &output, - ) - assert.NoError(c, err) - }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for policy get command") - - assert.Len(t, output.TagOwners, 1) - assert.Len(t, output.ACLs, 1) -} - -func TestPolicyBrokenConfigCommand(t *testing.T) { - IntegrationSkip(t) - - spec := ScenarioSpec{ - NodesPerUser: 1, - Users: []string{"user1"}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv( - []tsic.Option{}, - hsic.WithTestName("cli-policybad"), - hsic.WithConfigEnv(map[string]string{ - "HEADSCALE_POLICY_MODE": "database", // test sets invalid policy via CLI - }), - ) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - p := policyv2.Policy{ - ACLs: []policyv2.ACL{ - { - // This is an unknown action, so it will return an error - // and the config will not be applied. - Action: "unknown-action", - Protocol: "tcp", - Sources: []policyv2.Alias{wildcard()}, - Destinations: []policyv2.AliasWithPorts{ - aliasWithPorts(wildcard(), tailcfg.PortRangeAny), - }, - }, - }, - TagOwners: policyv2.TagOwners{ - policyv2.Tag("tag:exists"): policyv2.Owners{usernameOwner("user1@")}, - }, - } - - pBytes, _ := json.Marshal(p) //nolint:errchkjson - - policyFilePath := "/etc/headscale/policy.json" - - err = headscale.WriteFile(policyFilePath, pBytes) - require.NoError(t, err) - - // No policy is present at this time. - // Add a new policy from a file. - _, err = headscale.Execute( - []string{ - "headscale", - "policy", - "set", - "-f", - policyFilePath, - }, - ) - require.ErrorContains(t, err, `action="unknown-action" is not supported: invalid ACL action`) - - // The new policy was invalid, the old one should still be in place, which - // is none. - _, err = headscale.Execute( - []string{ - "headscale", - "policy", - "get", - "--output", - "json", - }, - ) - assert.ErrorContains(t, err, "acl policy not found") + return scenario, headscale } diff --git a/integration/cli_users_test.go b/integration/cli_users_test.go new file mode 100644 index 00000000..c2f95bf7 --- /dev/null +++ b/integration/cli_users_test.go @@ -0,0 +1,339 @@ +package integration + +import ( + "encoding/json" + "fmt" + "slices" + "testing" + "time" + + tcmp "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/integrationutil" + "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserCommand(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + Users: []string{"user1", "user2"}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-user")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + var ( + listUsers []*v1.User + result []string + ) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{ + "headscale", + "users", + "list", + "--output", + "json", + }, + &listUsers, + ) + assert.NoError(ct, err) + + slices.SortFunc(listUsers, sortWithID) + result = []string{listUsers[0].GetName(), listUsers[1].GetName()} + + assert.Equal( + ct, + []string{"user1", "user2"}, + result, + "Should have user1 and user2 in users list", + ) + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) + + _, err = headscale.Execute( + []string{ + "headscale", + "users", + "rename", + "--output=json", + fmt.Sprintf("--identifier=%d", listUsers[1].GetId()), + "--new-name=newname", + }, + ) + require.NoError(t, err) + + var listAfterRenameUsers []*v1.User + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{ + "headscale", + "users", + "list", + "--output", + "json", + }, + &listAfterRenameUsers, + ) + assert.NoError(ct, err) + + slices.SortFunc(listAfterRenameUsers, sortWithID) + result = []string{listAfterRenameUsers[0].GetName(), listAfterRenameUsers[1].GetName()} + + assert.Equal( + ct, + []string{"user1", "newname"}, + result, + "Should have user1 and newname after rename operation", + ) + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) + + var listByUsername []*v1.User + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal(headscale, + []string{ + "headscale", + "users", + "list", + "--output", + "json", + "--name=user1", + }, + &listByUsername, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user list by username") + + slices.SortFunc(listByUsername, sortWithID) + + want := []*v1.User{ + { + Id: 1, + Name: "user1", + Email: "user1@test.no", + }, + } + + if diff := tcmp.Diff(want, listByUsername, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + t.Errorf("unexpected users (-want +got):\n%s", diff) + } + + var listByID []*v1.User + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal(headscale, + []string{ + "headscale", + "users", + "list", + "--output", + "json", + "--identifier=1", + }, + &listByID, + ) + assert.NoError(c, err) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user list by ID") + + slices.SortFunc(listByID, sortWithID) + + want = []*v1.User{ + { + Id: 1, + Name: "user1", + Email: "user1@test.no", + }, + } + + if diff := tcmp.Diff(want, listByID, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + t.Errorf("unexpected users (-want +got):\n%s", diff) + } + + deleteResult, err := headscale.Execute( + []string{ + "headscale", + "users", + "destroy", + "--force", + // Delete "user1" + "--identifier=1", + }, + ) + require.NoError(t, err) + assert.Contains(t, deleteResult, "User destroyed") + + var listAfterIDDelete []*v1.User + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{ + "headscale", + "users", + "list", + "--output", + "json", + }, + &listAfterIDDelete, + ) + assert.NoError(ct, err) + + slices.SortFunc(listAfterIDDelete, sortWithID) + + want := []*v1.User{ + { + Id: 2, + Name: "newname", + Email: "user2@test.no", + }, + } + + if diff := tcmp.Diff(want, listAfterIDDelete, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + assert.Fail(ct, "unexpected users", "diff (-want +got):\n%s", diff) + } + }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) + + deleteResult, err = headscale.Execute( + []string{ + "headscale", + "users", + "destroy", + "--force", + "--name=newname", + }, + ) + require.NoError(t, err) + assert.Contains(t, deleteResult, "User destroyed") + + var listAfterNameDelete []v1.User + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err = executeAndUnmarshal(headscale, + []string{ + "headscale", + "users", + "list", + "--output", + "json", + }, + &listAfterNameDelete, + ) + assert.NoError(c, err) + assert.Empty(c, listAfterNameDelete) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user list after name delete") +} + +// TestUserCreateCommand exercises `headscale users create` with all of its +// optional flags (--display-name, --email, --picture-url), the --email list +// filter, and the validation error paths (duplicate name, missing flags). +func TestUserCreateCommand(t *testing.T) { + IntegrationSkip(t) + + // One pre-existing user with a node so the server holds real data while we + // create and inspect additional users via the CLI. + scenario, headscale := setupCLIScenario(t, "cli-usercreate", []string{"existing"}, 1) + defer scenario.ShutdownAssertNoPanics(t) + + // Create a user populated with every optional field. The created user is + // returned on stdout and round-tripped through the v1.User type. + created := assertJSONRoundtrip[*v1.User](t, headscale, []string{ + "headscale", + "users", + "create", + "cli-created", + "--display-name", "CLI Created", + "--email", "cli-created@example.com", + "--picture-url", "https://example.com/avatar.png", + "--output", "json", + }) + + assert.Equal(t, "cli-created", created.GetName()) + assert.Equal(t, "CLI Created", created.GetDisplayName()) + assert.Equal(t, "cli-created@example.com", created.GetEmail()) + assert.Equal(t, "https://example.com/avatar.png", created.GetProfilePicUrl()) + + // The created fields must survive a list query (read-after-write) and be + // filterable by email. + var byEmail []*v1.User + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{ + "headscale", + "users", + "list", + "--email", "cli-created@example.com", + "--output", "json", + }, + &byEmail, + ) + assert.NoError(ct, err) + assert.Len(ct, byEmail, 1, "exactly one user should match the email filter") + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user list by email") + + require.Len(t, byEmail, 1) + assert.Equal(t, "cli-created", byEmail[0].GetName()) + assert.Equal(t, "CLI Created", byEmail[0].GetDisplayName()) +} + +// TestUserCommandValidation exercises the validation and error permutations of +// the user subcommands and their flags: a missing name, a duplicate name, the +// required --new-name on rename, and the "--name or --identifier" requirement +// on rename/destroy. user1 already exists so the duplicate path has a conflict. +func TestUserCommandValidation(t *testing.T) { + IntegrationSkip(t) + + scenario, headscale := setupCLIScenario(t, "cli-userval", []string{"user1"}, 0) + defer scenario.ShutdownAssertNoPanics(t) + + // wantEmptyList means the command must succeed and return no users; + // otherwise the command must fail, matching wantErr when it is non-empty. + tests := []struct { + name string + args []string + wantErr string + wantEmptyList bool + }{ + {name: "create missing name", args: []string{"users", "create"}, wantErr: "missing parameters"}, + {name: "create duplicate", args: []string{"users", "create", "user1"}}, + {name: "rename missing new-name", args: []string{"users", "rename", "--identifier", "1"}, wantErr: "new-name"}, + {name: "rename missing selector", args: []string{"users", "rename", "--new-name", "x"}, wantErr: "--name or --identifier"}, + {name: "destroy missing selector", args: []string{"users", "destroy", "--force"}, wantErr: "--name or --identifier"}, + {name: "destroy nonexistent", args: []string{"users", "destroy", "--force", "--identifier", "99999"}}, + {name: "list nonexistent name is empty", args: []string{"users", "list", "--name", "ghost", "--output", "json"}, wantEmptyList: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := headscale.Execute(append([]string{"headscale"}, tt.args...)) + + switch { + case tt.wantEmptyList: + require.NoError(t, err) + + var users []v1.User + + require.NoError(t, json.Unmarshal([]byte(out), &users)) + require.Empty(t, users) + case tt.wantErr != "": + require.ErrorContains(t, err, tt.wantErr) + default: + require.Error(t, err) + } + }) + } +}