mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
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:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user