mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-17 16:36:02 +00:00
Pin every endpoint's behaviour with golden fixtures captured from the retiring grpc-gateway (timestamps and secrets neutralised). Error cases assert their HTTP status independently of the golden via assertStatus, so a blind regenerate cannot re-pin a wrong code; HEADSCALE_UPDATE_GOLDEN rewrites the fixtures. A unit test exercises the API-key auth middleware on the real server router. Exclude the emitted spec and goldens from pre-commit checks.
416 lines
13 KiB
Go
416 lines
13 KiB
Go
package hscontrol
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/key"
|
|
)
|
|
|
|
// nodeSeed carries fixed key material so isolated apps in a mutation parity test
|
|
// register byte-identical nodes; otherwise random keys would defeat body
|
|
// comparison.
|
|
type nodeSeed struct {
|
|
user string
|
|
machineKey key.MachinePrivate
|
|
nodeKey key.NodePrivate
|
|
hostname string
|
|
tags []string
|
|
}
|
|
|
|
func newNodeSeed(user, hostname string, tags ...string) nodeSeed {
|
|
return nodeSeed{
|
|
user: user,
|
|
machineKey: key.NewMachine(),
|
|
nodeKey: key.NewNode(),
|
|
hostname: hostname,
|
|
tags: tags,
|
|
}
|
|
}
|
|
|
|
// register inserts the user, a matching pre-auth key, and the node itself using
|
|
// the seed's fixed keys, returning the created node ID.
|
|
func (s nodeSeed) register(t *testing.T, app *Headscale) types.NodeID {
|
|
t.Helper()
|
|
|
|
user := app.state.CreateUserForTest(s.user)
|
|
|
|
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, s.tags)
|
|
require.NoError(t, err)
|
|
|
|
req := tailcfg.RegisterRequest{
|
|
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
|
|
NodeKey: s.nodeKey.Public(),
|
|
Hostinfo: &tailcfg.Hostinfo{Hostname: s.hostname},
|
|
}
|
|
|
|
_, err = app.handleRegisterWithAuthKey(req, s.machineKey.Public())
|
|
require.NoError(t, err)
|
|
|
|
node, found := app.state.GetNodeByNodeKey(s.nodeKey.Public())
|
|
require.True(t, found)
|
|
|
|
return node.ID()
|
|
}
|
|
|
|
func seedNodes(seeds ...nodeSeed) func(t *testing.T, app *Headscale) {
|
|
return func(t *testing.T, app *Headscale) {
|
|
t.Helper()
|
|
|
|
for _, s := range seeds {
|
|
s.register(t, app)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPIV1NodeGet(t *testing.T) {
|
|
t.Run("happy parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil)
|
|
})
|
|
|
|
t.Run("tagged node parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
// No tagOwners means policy won't authorise a tag, so register via the
|
|
// pre-auth key path, which forces tags regardless of policy.
|
|
seedNodes(newNodeSeed("bob", "node-b", "tag:initial"))(t, h.app)
|
|
h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil)
|
|
})
|
|
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodGet, "/api/v1/node/99999", nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodGet, "/api/v1/node/abc", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("huma response shape", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("carol", "node-c"))(t, h.app)
|
|
|
|
res := h.callHuma(http.MethodGet, "/api/v1/node/1", nil)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
|
|
var got struct {
|
|
Node map[string]any `json:"node"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(res.body, &got))
|
|
|
|
assert.Equal(t, "1", got.Node["id"])
|
|
assert.Equal(t, "node-c", got.Node["name"])
|
|
assert.Equal(t, "REGISTER_METHOD_AUTH_KEY", got.Node["registerMethod"])
|
|
// EmitUnpopulated parity: slices present as [], expiry as null.
|
|
assert.Equal(t, []any{}, got.Node["ipAddresses"])
|
|
assert.Equal(t, []any{}, got.Node["tags"])
|
|
assert.Nil(t, got.Node["expiry"])
|
|
assert.Contains(t, got.Node, "user")
|
|
assert.Contains(t, got.Node, "preAuthKey")
|
|
assert.Contains(t, got.Node, "createdAt")
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeList(t *testing.T) {
|
|
t.Run("empty returns empty array", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
|
|
res := h.callHuma(http.MethodGet, "/api/v1/node", nil)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
assert.JSONEq(t, `{"nodes":[]}`, string(res.body))
|
|
})
|
|
|
|
t.Run("empty parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
h.assertParity(t, http.MethodGet, "/api/v1/node", nil)
|
|
})
|
|
|
|
t.Run("all parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(
|
|
newNodeSeed("alice", "node-a"),
|
|
newNodeSeed("bob", "node-b"),
|
|
)(t, h.app)
|
|
h.assertParity(t, http.MethodGet, "/api/v1/node", nil)
|
|
})
|
|
|
|
t.Run("filter by user parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(
|
|
newNodeSeed("alice", "node-a"),
|
|
newNodeSeed("bob", "node-b"),
|
|
)(t, h.app)
|
|
h.assertParity(t, http.MethodGet, "/api/v1/node?user=alice", nil)
|
|
})
|
|
|
|
t.Run("unknown user parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
res := h.assertParity(t, http.MethodGet, "/api/v1/node?user=nope", nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeDelete(t *testing.T) {
|
|
t.Run("happy parity", func(t *testing.T) {
|
|
assertParityIsolated(t, seedNodes(newNodeSeed("alice", "node-a")),
|
|
http.MethodDelete, "/api/v1/node/1", nil)
|
|
})
|
|
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodDelete, "/api/v1/node/99999", nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodDelete, "/api/v1/node/abc", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeExpire(t *testing.T) {
|
|
// The embedded pre-auth key's masked prefix is random per app, so isolated
|
|
// body comparison is impossible. GetNode/ListNodes prove full serialisation;
|
|
// here we just assert the mutation took effect.
|
|
t.Run("huma expires node", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/node/1/expire", nil)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
|
|
var got struct {
|
|
Node map[string]any `json:"node"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(res.body, &got))
|
|
|
|
assert.Equal(t, "1", got.Node["id"])
|
|
// Expiry was nil before; expiring sets it to a concrete timestamp.
|
|
assert.NotNil(t, got.Node["expiry"])
|
|
})
|
|
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/expire", nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/expire", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeRename(t *testing.T) {
|
|
t.Run("huma renames node", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/node/1/rename/renamed-node", nil)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
|
|
var got struct {
|
|
Node map[string]any `json:"node"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(res.body, &got))
|
|
|
|
assert.Equal(t, "renamed-node", got.Node["givenName"])
|
|
})
|
|
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/rename/whatever", nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/rename/whatever", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeSetTags(t *testing.T) {
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/tags",
|
|
[]byte(`{"tags":["tag:foo"]}`))
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("empty tags parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags", []byte(`{"tags":[]}`))
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("unauthorized tag parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
// No tagOwners in policy → SetNodeTags rejects with InvalidArgument (400).
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags",
|
|
[]byte(`{"tags":["tag:server"]}`))
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/tags",
|
|
[]byte(`{"tags":["tag:foo"]}`))
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeSetApprovedRoutes(t *testing.T) {
|
|
t.Run("huma sets approved routes", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/node/1/approve_routes",
|
|
[]byte(`{"routes":[]}`))
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
|
|
var got struct {
|
|
Node map[string]any `json:"node"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(res.body, &got))
|
|
|
|
assert.Equal(t, "1", got.Node["id"])
|
|
// SubnetRoutes is recomputed from primary routes; empty here but present.
|
|
assert.Equal(t, []any{}, got.Node["approvedRoutes"])
|
|
assert.Equal(t, []any{}, got.Node["subnetRoutes"])
|
|
})
|
|
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/approve_routes",
|
|
[]byte(`{"routes":["10.0.0.0/24"]}`))
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/approve_routes",
|
|
[]byte(`{"routes":[]}`))
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeRegister(t *testing.T) {
|
|
t.Run("happy parity", func(t *testing.T) {
|
|
authID := types.MustAuthID()
|
|
mk := key.NewMachine()
|
|
nk := key.NewNode()
|
|
|
|
seed := func(t *testing.T, app *Headscale) {
|
|
t.Helper()
|
|
|
|
app.state.CreateUserForTest("alice")
|
|
|
|
regData := &types.RegistrationData{
|
|
NodeKey: nk.Public(),
|
|
MachineKey: mk.Public(),
|
|
Hostname: "registered-node",
|
|
}
|
|
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData))
|
|
}
|
|
|
|
assertParityIsolated(t, seed, http.MethodPost,
|
|
"/api/v1/node/register?user=alice&key="+authID.String(), nil)
|
|
})
|
|
|
|
t.Run("invalid key parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
res := h.assertParity(t, http.MethodPost,
|
|
"/api/v1/node/register?user=alice&key=invalidkey", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("unknown user parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost,
|
|
"/api/v1/node/register?user=nope&key="+types.MustAuthID().String(), nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeBackfillIPs(t *testing.T) {
|
|
t.Run("confirmed empty parity", func(t *testing.T) {
|
|
assertParityIsolated(t, nil, http.MethodPost,
|
|
"/api/v1/node/backfillips?confirmed=true", nil)
|
|
})
|
|
|
|
t.Run("confirmed returns empty array", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/node/backfillips?confirmed=true", nil)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
assert.JSONEq(t, `{"changes":[]}`, string(res.body))
|
|
})
|
|
|
|
t.Run("unconfirmed parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/backfillips", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeDebugCreate(t *testing.T) {
|
|
t.Run("unknown user parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
body := []byte(`{"user":"nope","key":"` + types.MustAuthID().String() +
|
|
`","name":"dbg","routes":[]}`)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid key parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
|
|
body := []byte(`{"user":"alice","key":"badkey","name":"dbg","routes":[]}`)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
// The handler mints fresh key material per call, so isolated apps can't
|
|
// produce byte-identical bodies; assert the shape directly instead.
|
|
t.Run("huma response shape", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
h.app.state.CreateUserForTest("alice")
|
|
|
|
body := []byte(`{"user":"alice","key":"` + types.MustAuthID().String() +
|
|
`","name":"dbgnode","routes":["10.0.0.0/24"]}`)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/debug/node", body)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
|
|
var got struct {
|
|
Node map[string]any `json:"node"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(res.body, &got))
|
|
|
|
assert.Equal(t, "dbgnode", got.Node["name"])
|
|
assert.Equal(t, "REGISTER_METHOD_UNSPECIFIED", got.Node["registerMethod"])
|
|
assert.Equal(t, []any{"10.0.0.0/24"}, got.Node["availableRoutes"])
|
|
// The synthetic echo node has no pre-auth key: emitted as null.
|
|
assert.Nil(t, got.Node["preAuthKey"])
|
|
// Zero-time expiry/lastSeen are emitted as the zero instant, not null.
|
|
assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["expiry"])
|
|
assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["lastSeen"])
|
|
})
|
|
}
|