cmd/headscale: cover CLI output rendering for all formats

Unit-test formatOutput across json/json-line/yaml for every printed API
type and exercise the node table renderers, whose manual key/IP decode
had no coverage. These paths only ran in -o json before.
This commit is contained in:
Kristoffer Dalby
2026-06-17 20:35:26 +00:00
parent 7526401656
commit febef2c7cd

View File

@@ -6,43 +6,147 @@ import (
"testing"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
"tailscale.com/types/key"
)
// sampleNode builds a fully-populated node with valid machine/node keys so the
// table renderer's UnmarshalText/ParseAddr paths exercise real data.
func sampleNode(t *testing.T) apiv1.Node {
t.Helper()
mkey, err := key.NewMachine().Public().MarshalText()
if err != nil {
t.Fatalf("machine key: %v", err)
}
nkey, err := key.NewNode().Public().MarshalText()
if err != nil {
t.Fatalf("node key: %v", err)
}
return apiv1.Node{
ID: apiv1.NewOptUint64(7),
Name: apiv1.NewOptString("host7"),
GivenName: apiv1.NewOptString("host7"),
MachineKey: apiv1.NewOptString(string(mkey)),
NodeKey: apiv1.NewOptString(string(nkey)),
User: apiv1.NewOptUser(apiv1.User{Name: apiv1.NewOptString("alice")}),
IpAddresses: []string{"100.64.0.7", "fd7a:115c:a1e0::7"},
Online: apiv1.NewOptBool(true),
// Expiry, LastSeen, Tags deliberately unset.
}
}
// TestFormatOutputValueWithUnsetOptional guards against a regression where the
// generated API types, passed by value with unset optional fields, fail to
// marshal because stdlib reflection calls Opt*.MarshalJSON directly.
func TestFormatOutputValueWithUnsetOptional(t *testing.T) {
// DisplayName, Email, CreatedAt and PictureURL are intentionally unset.
user := apiv1.User{
ID: apiv1.NewOptUint64(1),
Name: apiv1.NewOptString("alice"),
cases := map[string]any{
"user": apiv1.User{ID: apiv1.NewOptUint64(1), Name: apiv1.NewOptString("alice")},
"node": sampleNode(t),
"preauthkey": apiv1.PreAuthKey{ID: apiv1.NewOptUint64(2), Key: apiv1.NewOptString("abc")},
"apikey": apiv1.ApiKey{ID: apiv1.NewOptUint64(3), Prefix: apiv1.NewOptString("pref")},
}
for _, format := range []string{outputFormatJSON, outputFormatJSONLine, outputFormatYAML} {
out, err := formatOutput(user, "", format)
if err != nil {
t.Fatalf("formatOutput(%s) on value type: %v", format, err)
formats := []string{outputFormatJSON, outputFormatJSONLine, outputFormatYAML}
for name, val := range cases {
for _, format := range formats {
// Single value.
out, err := formatOutput(val, "", format)
if err != nil {
t.Fatalf("formatOutput(%s, %s) value: %v", name, format, err)
}
if strings.TrimSpace(out) == "" {
t.Errorf("formatOutput(%s, %s) value: empty output", name, format)
}
// Slice of values (the "list" commands).
sliceOut, err := formatOutput([]any{val}, "", format)
if err != nil {
t.Fatalf("formatOutput(%s, %s) slice: %v", name, format, err)
}
if strings.TrimSpace(sliceOut) == "" {
t.Errorf("formatOutput(%s, %s) slice: empty output", name, format)
}
}
if !strings.Contains(out, "alice") {
t.Errorf("formatOutput(%s) missing name: %q", format, out)
}
}
// A slice of value types must work too (e.g. "users list").
listOut, err := formatOutput([]apiv1.User{user}, "", outputFormatJSON)
if err != nil {
t.Fatalf("formatOutput(json) on slice of value type: %v", err)
}
var decoded []map[string]any
err = json.Unmarshal([]byte(listOut), &decoded)
if err != nil {
t.Fatalf("list output is not valid JSON: %v\n%s", err, listOut)
}
if len(decoded) != 1 || decoded[0]["name"] != "alice" {
t.Errorf("unexpected list output: %s", listOut)
}
}
// TestFormatOutputJSONShape pins that a value-typed object serialises by its
// JSON field names (not the Go struct shape) and omits unset optionals.
func TestFormatOutputJSONShape(t *testing.T) {
user := apiv1.User{ID: apiv1.NewOptUint64(1), Name: apiv1.NewOptString("alice")}
out, err := formatOutput(user, "", outputFormatJSON)
if err != nil {
t.Fatalf("formatOutput: %v", err)
}
var decoded map[string]any
err = json.Unmarshal([]byte(out), &decoded)
if err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, out)
}
if decoded["name"] != "alice" || decoded["id"] != float64(1) {
t.Errorf("unexpected JSON shape: %s", out)
}
if _, present := decoded["displayName"]; present {
t.Errorf("unset optional should be omitted, got: %s", out)
}
}
// TestNodesToPtables exercises the default table renderer, whose manual
// MachineKey/NodeKey UnmarshalText and IP ParseAddr have no other coverage.
func TestNodesToPtables(t *testing.T) {
data, err := nodesToPtables([]apiv1.Node{sampleNode(t)})
if err != nil {
t.Fatalf("nodesToPtables: %v", err)
}
if len(data) != 2 {
t.Fatalf("want header + 1 row, got %d rows", len(data))
}
row := strings.Join(data[1], "|")
for _, want := range []string{"host7", "alice", "100.64.0.7", "fd7a:115c:a1e0::7"} {
if !strings.Contains(row, want) {
t.Errorf("row missing %q: %s", want, row)
}
}
}
// TestNodesToPtablesEmptyNodeKey documents that a missing NodeKey makes the
// table render fail loudly rather than silently — so a decode regression that
// drops NodeKey over HTTP is caught at the CLI, not just in -o json.
func TestNodesToPtablesEmptyNodeKey(t *testing.T) {
node := sampleNode(t)
node.NodeKey = apiv1.OptString{}
_, err := nodesToPtables([]apiv1.Node{node})
if err == nil {
t.Error("expected error for empty NodeKey, got nil")
}
}
// TestNodeRoutesToPtables renders the list-routes table, which has zero
// integration coverage.
func TestNodeRoutesToPtables(t *testing.T) {
node := sampleNode(t)
node.SubnetRoutes = []string{"10.0.0.0/24"}
node.ApprovedRoutes = []string{"10.0.0.0/24"}
data := nodeRoutesToPtables([]apiv1.Node{node})
if len(data) != 2 {
t.Fatalf("want header + 1 row, got %d rows", len(data))
}
if !strings.Contains(strings.Join(data[1], "|"), "10.0.0.0/24") {
t.Errorf("route row missing approved route: %v", data[1])
}
}