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.
This commit is contained in:
Kristoffer Dalby
2026-06-18 11:37:06 +00:00
parent 00afce77b1
commit 9aba06ec7d
8 changed files with 2601 additions and 1730 deletions
+320
View File
@@ -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)
})
}
}
+49
View File
@@ -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)
})
}
}
File diff suppressed because it is too large Load Diff
+164
View File
@@ -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")
}
+397
View File
@@ -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)
})
}
}
+47
View File
@@ -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")
})
}
+49 -1730
View File
File diff suppressed because it is too large Load Diff
+339
View File
@@ -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)
}
})
}
}