Files
headscale/cmd/headscale/cli/users_test.go
T
Kristoffer Dalby 62f89ca25d cli: accept a user name in preauthkeys create --user
Resolved through lookupUser like the users commands, so scripts skip the
users list round trip. Digit-only values stay IDs.
2026-09-28 21:42:19 +02:00

238 lines
6.3 KiB
Go

package cli
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/spf13/cobra"
)
// filterUsersServer serves GET /api/v1/user responses filtered like the
// real API: by the id, name, and email query parameters.
func filterUsersServer(t *testing.T, users []clientv1.User) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
filtered := make([]clientv1.User, 0, len(users))
for _, user := range users {
if name := query.Get("name"); name != "" && user.Name != name {
continue
}
if id := query.Get("id"); id != "" && user.Id != id {
continue
}
if email := query.Get("email"); email != "" && user.Email != email {
continue
}
filtered = append(filtered, user)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(clientv1.ListUsersOutputBody{Users: filtered})
if err != nil {
t.Errorf("encoding response: %v", err)
}
}))
}
func commandWithUserFlags(t *testing.T, identifier, name string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{}
usernameAndIDFlag(cmd)
if identifier != "" {
err := cmd.Flags().Set("identifier", identifier)
if err != nil {
t.Fatalf("setting identifier flag: %v", err)
}
}
if name != "" {
err := cmd.Flags().Set("name", name)
if err != nil {
t.Fatalf("setting name flag: %v", err)
}
}
return cmd
}
func TestResolveSingleUser(t *testing.T) {
lukas := clientv1.User{Id: "6", Name: "lukas", Email: "login@lukasrunge.de"}
hannes := clientv1.User{Id: "9", Name: "hannes@rueger.events", Email: "hannes@rueger.events"}
hannesDup := clientv1.User{Id: "10", Name: "hannes@rueger.events", Email: "other@example.com"}
tests := []struct {
name string
users []clientv1.User
identifier string
flagName string
wantId string
wantErr bool
wantErrIs error
// wantErrHas are substrings the error message must contain.
wantErrHas []string
}{
{
// Regression: renaming by name used to return the raw flag
// id (0 when unset), so the rename request targeted user 0.
name: "resolves by name to the matched user's identifier",
users: []clientv1.User{lukas, hannes},
flagName: "hannes@rueger.events",
wantId: "9",
},
{
name: "resolves by identifier",
users: []clientv1.User{hannes},
identifier: "9",
wantId: "9",
},
{
// Regression: zero matches used to be reported as
// "multiple users match query".
name: "no match is a not-found error",
users: []clientv1.User{lukas},
flagName: "nobody@example.com",
wantErr: true,
wantErrIs: errUserNotFound,
},
{
// OIDC users can share a name, see issue #3429.
name: "multiple matches are an ambiguity error listing the matches",
users: []clientv1.User{hannes, hannesDup},
flagName: "hannes@rueger.events",
wantErr: true,
wantErrIs: errMultipleUsersMatch,
wantErrHas: []string{
"id=9 name=hannes@rueger.events email=hannes@rueger.events",
"id=10 name=hannes@rueger.events email=other@example.com",
},
},
{
// Regression: --identifier 0 was sent to the API as "no
// filter", listing every user and failing as ambiguous.
name: "identifier zero is rejected before calling the API",
users: []clientv1.User{lukas, hannes},
identifier: "0",
wantErr: true,
wantErrIs: errInvalidIdentifier,
},
{
name: "no flags is a usage error",
users: []clientv1.User{lukas},
wantErr: true,
wantErrIs: errFlagRequired,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := filterUsersServer(t, tt.users)
defer server.Close()
client, err := clientv1.NewClientWithResponses(server.URL)
if err != nil {
t.Fatalf("creating client: %v", err)
}
cmd := commandWithUserFlags(t, tt.identifier, tt.flagName)
id, user, err := resolveSingleUser(context.Background(), client, cmd)
if tt.wantErr {
if err == nil {
t.Fatalf("resolveSingleUser() error = nil, want error")
}
if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) {
t.Fatalf("resolveSingleUser() error = %v, want %v", err, tt.wantErrIs)
}
for _, want := range tt.wantErrHas {
if !strings.Contains(err.Error(), want) {
t.Errorf("resolveSingleUser() error = %q, want it to contain %q", err, want)
}
}
return
}
if err != nil {
t.Fatalf("resolveSingleUser() error = %v", err)
}
if id != tt.wantId {
t.Errorf("resolveSingleUser() id = %q, want %q", id, tt.wantId)
}
if user.Id != tt.wantId {
t.Errorf("resolveSingleUser() user.Id = %q, want %q", user.Id, tt.wantId)
}
})
}
}
func TestUserIDFromArg(t *testing.T) {
alice := clientv1.User{Id: "3", Name: "alice"}
digits := clientv1.User{Id: "7", Name: "42"}
aliceDup := clientv1.User{Id: "8", Name: "alice"}
tests := []struct {
name string
users []clientv1.User
arg string
wantID string
wantErr bool
}{
{name: "unset stays unset", arg: "", wantID: ""},
{name: "number is an ID", users: []clientv1.User{alice}, arg: "3", wantID: "3"},
// Numbers never hit the name lookup, so user "42" needs its ID.
{name: "digit-only name is an ID", users: []clientv1.User{digits}, arg: "42", wantID: "42"},
{name: "name resolves to its ID", users: []clientv1.User{alice, digits}, arg: "alice", wantID: "3"},
{name: "unknown name is an error", users: []clientv1.User{alice}, arg: "bob", wantErr: true},
{name: "ambiguous name is an error", users: []clientv1.User{alice, aliceDup}, arg: "alice", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := filterUsersServer(t, tt.users)
defer server.Close()
client, err := clientv1.NewClientWithResponses(server.URL)
if err != nil {
t.Fatalf("creating client: %v", err)
}
id, err := userIDFromArg(context.Background(), client, tt.arg)
if tt.wantErr {
if err == nil {
t.Fatalf("userIDFromArg(%q) error = nil, want error", tt.arg)
}
return
}
if err != nil {
t.Fatalf("userIDFromArg(%q) error = %v", tt.arg, err)
}
if id != tt.wantID {
t.Errorf("userIDFromArg(%q) = %q, want %q", tt.arg, id, tt.wantID)
}
})
}
}