hscontrol: add v2 API contract tests

Drive each endpoint in-process with humatest and validate both the
wire shapes and the server-side state: keys, devices, ACL, settings.
This commit is contained in:
Kristoffer Dalby
2026-06-20 20:16:51 +00:00
parent d5555c6b8c
commit 13ffd958ed
4 changed files with 1040 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
package hscontrol
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/danielgtaylor/huma/v2/humatest"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const allowAllPolicy = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
// storedPolicy returns the HuJSON actually persisted in the DB (server-side
// ground truth), or "" when none is set.
func storedPolicy(t *testing.T, app *Headscale) string {
t.Helper()
p, err := app.state.GetPolicy()
if errors.Is(err, types.ErrPolicyNotFound) {
return ""
}
require.NoError(t, err)
return p.Data
}
// policyETagOf recomputes the expected ETag independently of the handler, so a
// handler that hashed the wrong buffer is caught.
func policyETagOf(data string) string {
sum := sha256.Sum256([]byte(data))
return `"` + hex.EncodeToString(sum[:]) + `"`
}
// setACL POSTs a raw policy body with the given content type and optional
// headers (e.g. "If-Match: ...").
func setACL(t *testing.T, api humatest.TestAPI, body, contentType string, headers ...string) *httptest.ResponseRecorder {
t.Helper()
args := make([]any, 0, 2+len(headers))
args = append(args, bytes.NewReader([]byte(body)), "Content-Type: "+contentType)
for _, h := range headers {
args = append(args, h)
}
return api.Post("/api/v2/tailnet/-/acl", args...)
}
func TestAPIv2ACLDefaultWhenUnset(t *testing.T) {
app := createTestApp(t)
api := registerAPIV2(t, app)
resp := api.Get("/api/v2/tailnet/-/acl")
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
assert.NotEmpty(t, resp.Header().Get("Etag"))
assert.Contains(t, resp.Body.String(), `"acls"`)
// GET did not materialize a stored policy.
assert.Empty(t, storedPolicy(t, app))
// Content-addressed: a second GET returns the same ETag.
assert.Equal(t, resp.Header().Get("Etag"), api.Get("/api/v2/tailnet/-/acl").Header().Get("Etag"))
}
func TestAPIv2ACLContentNegotiation(t *testing.T) {
api := registerAPIV2(t, createTestApp(t))
jsonResp := api.Get("/api/v2/tailnet/-/acl")
huResp := api.Get("/api/v2/tailnet/-/acl", "Accept: application/hujson")
assert.Equal(t, "application/json", jsonResp.Header().Get("Content-Type"))
assert.Equal(t, "application/hujson", huResp.Header().Get("Content-Type"))
// Same bytes, same ETag — the ETag is over content, not type.
assert.Equal(t, jsonResp.Body.Bytes(), huResp.Body.Bytes())
assert.Equal(t, jsonResp.Header().Get("Etag"), huResp.Header().Get("Etag"))
}
func TestAPIv2ACLSetCanonicalJSON(t *testing.T) {
app := createTestApp(t)
api := registerAPIV2(t, app)
set := setACL(t, api, allowAllPolicy, "application/json")
require.Equalf(t, http.StatusOK, set.Code, "body: %s", set.Body)
setETag := set.Header().Get("Etag")
assert.NotEmpty(t, setETag)
// (a) tool's own get reflects it, same ETag.
get := api.Get("/api/v2/tailnet/-/acl")
assert.Contains(t, get.Body.String(), `"action":"accept"`)
assert.Equal(t, setETag, get.Header().Get("Etag"))
// (b) server-side: exact stored bytes.
assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app))
// (c) ETag is the sha256 of those bytes.
assert.Equal(t, policyETagOf(allowAllPolicy), setETag)
}
func TestAPIv2ACLSetHuJSONWithComments(t *testing.T) {
app := createTestApp(t)
api := registerAPIV2(t, app)
// Line comment + trailing comma: valid HuJSON, invalid strict JSON. The
// server accepts it (the SDK sends the policy file this way) and standardizes
// it on store; the ACL content survives even though comments are blanked.
policy := "{\n // allow all\n \"acls\": [{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}],\n}"
set := setACL(t, api, policy, "application/hujson")
require.Equalf(t, http.StatusOK, set.Code, "body: %s", set.Body)
// Server-side: stored, parseable, ACL intact.
stored := storedPolicy(t, app)
assert.Contains(t, stored, `"action":"accept"`)
// GET round-trips the stored form and its content-addressed ETag.
raw := api.Get("/api/v2/tailnet/-/acl", "Accept: application/hujson")
assert.Equal(t, stored, raw.Body.String())
assert.Equal(t, policyETagOf(stored), raw.Header().Get("Etag"))
}
func TestAPIv2ACLETagChangesOnChangeStableOnNoop(t *testing.T) {
app := createTestApp(t)
api := registerAPIV2(t, app)
etag1 := setACL(t, api, allowAllPolicy, "application/json").Header().Get("Etag")
stored1 := storedPolicy(t, app)
p2 := `{"hosts":{"h":"100.64.0.1"},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
etag2 := setACL(t, api, p2, "application/json").Header().Get("Etag")
assert.NotEqual(t, etag1, etag2, "etag changes when policy changes")
assert.NotEqual(t, stored1, storedPolicy(t, app))
// No-op re-set keeps the ETag stable.
etag3 := setACL(t, api, p2, "application/json").Header().Get("Etag")
assert.Equal(t, etag2, etag3)
assert.Equal(t, p2, storedPolicy(t, app))
}
func TestAPIv2ACLIfMatchPreconditions(t *testing.T) {
app := createTestApp(t)
api := registerAPIV2(t, app)
etag := setACL(t, api, allowAllPolicy, "application/json").Header().Get("Etag")
p2 := `{"hosts":{"h":"100.64.0.1"},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
// Match -> 200, applies.
require.Equal(t, http.StatusOK, setACL(t, api, p2, "application/json", "If-Match: "+etag).Code)
assert.Equal(t, p2, storedPolicy(t, app))
// Mismatch -> 412, server unchanged.
before := storedPolicy(t, app)
assert.Equal(t, http.StatusPreconditionFailed,
setACL(t, api, allowAllPolicy, "application/json", `If-Match: "deadbeef"`).Code)
assert.Equal(t, before, storedPolicy(t, app), "rejected write left the policy untouched")
// Absent -> unconditional 200.
require.Equal(t, http.StatusOK, setACL(t, api, allowAllPolicy, "application/json").Code)
assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app))
}
func TestAPIv2ACLIfMatchTsDefault(t *testing.T) {
// No policy set: ts-default matches the allow-all default -> 200.
app := createTestApp(t)
api := registerAPIV2(t, app)
require.Equal(t, http.StatusOK,
setACL(t, api, allowAllPolicy, "application/json", `If-Match: "ts-default"`).Code)
assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app))
// A non-default policy is set: ts-default no longer matches -> 412.
assert.Equal(t, http.StatusPreconditionFailed,
setACL(t, api, `{"hosts":{"h":"100.64.0.1"},"acls":[]}`, "application/json", `If-Match: "ts-default"`).Code)
}
func TestAPIv2ACLInvalidPolicyAtomicity(t *testing.T) {
app := createTestApp(t)
api := registerAPIV2(t, app)
good := setACL(t, api, allowAllPolicy, "application/json")
require.Equal(t, http.StatusOK, good.Code)
goodETag := good.Header().Get("Etag")
// Malformed body -> 400, and the stored policy is unchanged (atomicity).
bad := setACL(t, api, `{ this is not valid`, "application/json")
assert.Equal(t, http.StatusBadRequest, bad.Code)
assert.Contains(t, bad.Body.String(), `"message"`)
assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app))
// Wire-level: GET still serves the good policy + its ETag (no drift).
get := api.Get("/api/v2/tailnet/-/acl")
assert.Equal(t, goodETag, get.Header().Get("Etag"))
assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app))
}
func TestAPIv2ACLNonDefaultTailnet404(t *testing.T) {
api := registerAPIV2(t, createTestApp(t))
assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/example.com/acl").Code)
assert.Equal(t, http.StatusNotFound,
api.Post("/api/v2/tailnet/example.com/acl", bytes.NewReader([]byte(allowAllPolicy))).Code)
}
func TestAPIv2ACLFileModeReadOnly(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "acl.hujson")
fileBytes := "{\n // file-managed\n \"acls\": [{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}],\n}"
require.NoError(t, os.WriteFile(path, []byte(fileBytes), 0o600))
cfg := &types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeFile, Path: path}}
_, api := humatest.New(t, apiv2.Config())
apiv2.Register(api, apiv2.Backend{Cfg: cfg})
// GET serves the file bytes + their ETag.
get := api.Get("/api/v2/tailnet/-/acl")
require.Equalf(t, http.StatusOK, get.Code, "body: %s", get.Body)
assert.Equal(t, fileBytes, get.Body.String())
assert.Equal(t, policyETagOf(fileBytes), get.Header().Get("Etag"))
// POST is rejected in file mode; the file is unchanged.
set := setACL(t, api, allowAllPolicy, "application/json")
assert.Equal(t, http.StatusBadRequest, set.Code)
assert.Contains(t, set.Body.String(), "database")
after, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, fileBytes, string(after))
}
+343
View File
@@ -0,0 +1,343 @@
package hscontrol
import (
"encoding/json"
"net/http"
"strconv"
"testing"
"time"
"github.com/danielgtaylor/huma/v2/humatest"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// registerAPIV2 builds a humatest API with the v2 operations registered over the
// app's state, with no auth middleware (the contract tests exercise wire shapes;
// auth is covered by TestAPIv2). The full Backend (Change + Cfg) is wired so the
// device/acl write handlers work.
func registerAPIV2(t *testing.T, app *Headscale) humatest.TestAPI {
t.Helper()
_, api := humatest.New(t, apiv2.Config())
apiv2.Register(api, apiv2.Backend{State: app.state, Change: app.Change, Cfg: app.cfg})
return api
}
// deviceTestEnv is one seeded, registered, user-owned node plus the v2 API.
type deviceTestEnv struct {
app *Headscale
api humatest.TestAPI
user *types.User
nodeID types.NodeID
deviceID string
}
// newDeviceTestEnv seeds a registered user-owned node into the NodeStore, with
// tag:ci and tag:prod declared in policy so tagging is permitted.
func newDeviceTestEnv(t *testing.T) deviceTestEnv {
t.Helper()
app := createTestApp(t)
user := app.state.CreateUserForTest("dut-user")
_, err := app.state.SetPolicy([]byte(
`{"tagOwners":{"tag:ci":["` + user.Name + `@"],"tag:prod":["` + user.Name + `@"]},` +
`"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`,
))
require.NoError(t, err)
node := app.state.CreateRegisteredNodeForTest(user, "contract-dut")
node.User = user
view := app.state.PutNodeInStoreForTest(*node)
return deviceTestEnv{
app: app,
api: registerAPIV2(t, app),
user: user,
nodeID: view.ID(),
deviceID: strconv.FormatUint(uint64(view.ID()), 10),
}
}
// srvNode reloads the node from the NodeStore — the server-side source of truth.
func (e deviceTestEnv) srvNode(t *testing.T) types.NodeView {
t.Helper()
v, ok := e.app.state.GetNodeByID(e.nodeID)
require.True(t, ok, "node must exist in the NodeStore")
require.True(t, v.Valid())
return v
}
// seedExpiry stamps a future expiry so set-key(disable) is a real transition.
func (e deviceTestEnv) seedExpiry(t *testing.T, at time.Time) {
t.Helper()
_, _, err := e.app.state.SetNodeExpiry(e.nodeID, &at)
require.NoError(t, err)
}
func getDevice(t *testing.T, api humatest.TestAPI, deviceID string) apiv2.Device {
t.Helper()
resp := api.Get("/api/v2/device/" + deviceID)
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
var dev apiv2.Device
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dev))
return dev
}
func getDeviceRoutes(t *testing.T, api humatest.TestAPI, deviceID string) apiv2.DeviceRoutes {
t.Helper()
resp := api.Get("/api/v2/device/" + deviceID + "/routes")
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
var routes apiv2.DeviceRoutes
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &routes))
return routes
}
func approvedRouteStrings(nv types.NodeView) []string {
return util.PrefixesToString(nv.ApprovedRoutes().AsSlice())
}
func TestAPIv2Device_Get(t *testing.T) {
e := newDeviceTestEnv(t)
resp := e.api.Get("/api/v2/device/" + e.deviceID + "?fields=all")
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
var dev apiv2.Device
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dev))
assert.Equal(t, e.deviceID, dev.ID)
assert.Equal(t, e.deviceID, dev.NodeID)
assert.Equal(t, "contract-dut", dev.Hostname)
assert.Equal(t, "contract-dut", dev.Name)
assert.True(t, dev.Authorized)
assert.Equal(t, e.user.Username(), dev.User)
assert.NotNil(t, dev.Tags)
assert.True(t, dev.KeyExpiryDisabled, "seeded node has no expiry")
assert.NotEmpty(t, dev.Addresses)
// Server-side cross-check.
n := e.srvNode(t)
assert.Equal(t, n.GivenName(), dev.Name)
assert.False(t, n.IsTagged())
assert.True(t, n.User().Valid())
assert.False(t, n.Expiry().Valid())
assert.Equal(t, n.IPsAsString(), dev.Addresses)
}
func TestAPIv2Device_Get_UnknownID_404(t *testing.T) {
e := newDeviceTestEnv(t)
assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/device/999999").Code)
assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/device/not-a-number").Code)
}
func TestAPIv2Device_List(t *testing.T) {
e := newDeviceTestEnv(t)
list := func() []apiv2.Device {
t.Helper()
resp := e.api.Get("/api/v2/tailnet/-/devices")
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
var out struct {
Devices []apiv2.Device `json:"devices"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
return out.Devices
}
assert.True(t, containsDeviceID(list(), e.deviceID))
assert.Len(t, list(), e.app.state.ListNodes().Len(), "list count == ListNodes")
// A second node appears; deleting it removes it from both the list and state.
node2 := e.app.state.CreateRegisteredNodeForTest(e.user, "contract-dut-2")
node2.User = e.user
view2 := e.app.state.PutNodeInStoreForTest(*node2)
id2 := strconv.FormatUint(uint64(view2.ID()), 10)
assert.True(t, containsDeviceID(list(), id2))
require.Equal(t, http.StatusOK, e.api.Delete("/api/v2/device/"+id2).Code)
assert.False(t, containsDeviceID(list(), id2))
_, ok := e.app.state.GetNodeByID(view2.ID())
assert.False(t, ok)
assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/tailnet/example.com/devices").Code)
}
func TestAPIv2Device_SetName(t *testing.T) {
e := newDeviceTestEnv(t)
require.Equal(t, http.StatusOK,
e.api.Post("/api/v2/device/"+e.deviceID+"/name", map[string]any{"name": "renamed-dut"}).Code)
assert.Equal(t, "renamed-dut", getDevice(t, e.api, e.deviceID).Name)
assert.Equal(t, "renamed-dut", e.srvNode(t).GivenName())
// Rename again.
require.Equal(t, http.StatusOK,
e.api.Post("/api/v2/device/"+e.deviceID+"/name", map[string]any{"name": "renamed-again"}).Code)
assert.Equal(t, "renamed-again", getDevice(t, e.api, e.deviceID).Name)
assert.Equal(t, "renamed-again", e.srvNode(t).GivenName())
// Invalid DNS label is rejected; the name is unchanged.
bad := e.api.Post("/api/v2/device/"+e.deviceID+"/name", map[string]any{"name": "Invalid_Name!"})
assert.Equal(t, http.StatusBadRequest, bad.Code)
assert.Contains(t, bad.Body.String(), `"message"`)
assert.Equal(t, "renamed-again", e.srvNode(t).GivenName())
}
func TestAPIv2Device_SetTags(t *testing.T) {
e := newDeviceTestEnv(t)
setTags := func(tags []string) int {
return e.api.Post("/api/v2/device/"+e.deviceID+"/tags", map[string]any{"tags": tags}).Code
}
// One tag flips the node to tag-owned.
require.Equal(t, http.StatusOK, setTags([]string{"tag:ci"}))
assert.Equal(t, []string{"tag:ci"}, getDevice(t, e.api, e.deviceID).Tags)
n := e.srvNode(t)
assert.True(t, n.IsTagged())
assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice())
assert.False(t, n.User().Valid())
assert.Equal(t, types.TaggedDevices.Username(), getDevice(t, e.api, e.deviceID).User)
// Two tags (sorted).
require.Equal(t, http.StatusOK, setTags([]string{"tag:ci", "tag:prod"}))
assert.Equal(t, []string{"tag:ci", "tag:prod"}, getDevice(t, e.api, e.deviceID).Tags)
assert.Equal(t, []string{"tag:ci", "tag:prod"}, e.srvNode(t).Tags().AsSlice())
// A different single tag replaces the set.
require.Equal(t, http.StatusOK, setTags([]string{"tag:prod"}))
assert.Equal(t, []string{"tag:prod"}, e.srvNode(t).Tags().AsSlice())
// Remove-all is a no-op: tags survive.
require.Equal(t, http.StatusOK, setTags([]string{}))
assert.Equal(t, []string{"tag:prod"}, e.srvNode(t).Tags().AsSlice())
assert.True(t, e.srvNode(t).IsTagged())
// An undeclared tag is rejected with 400 (mapError now maps the sentinel),
// and the server-side tags are unchanged.
assert.Equal(t, http.StatusBadRequest, setTags([]string{"tag:nope"}))
assert.Equal(t, []string{"tag:prod"}, e.srvNode(t).Tags().AsSlice())
}
func TestAPIv2Device_SetKey(t *testing.T) {
e := newDeviceTestEnv(t)
e.seedExpiry(t, time.Now().Add(24*time.Hour))
require.True(t, e.srvNode(t).Expiry().Valid(), "precondition: node has an expiry")
assert.False(t, getDevice(t, e.api, e.deviceID).KeyExpiryDisabled)
require.Equal(t, http.StatusOK,
e.api.Post("/api/v2/device/"+e.deviceID+"/key", map[string]any{"keyExpiryDisabled": true}).Code)
assert.True(t, getDevice(t, e.api, e.deviceID).KeyExpiryDisabled)
assert.False(t, e.srvNode(t).Expiry().Valid(), "expiry cleared")
// Re-enable is accepted as a no-op; expiry stays cleared.
require.Equal(t, http.StatusOK,
e.api.Post("/api/v2/device/"+e.deviceID+"/key", map[string]any{"keyExpiryDisabled": false}).Code)
assert.True(t, getDevice(t, e.api, e.deviceID).KeyExpiryDisabled)
assert.False(t, e.srvNode(t).Expiry().Valid())
}
func TestAPIv2Device_SetRoutes(t *testing.T) {
e := newDeviceTestEnv(t)
setRoutes := func(routes []string) apiv2.DeviceRoutes {
t.Helper()
resp := e.api.Post("/api/v2/device/"+e.deviceID+"/routes", map[string]any{"routes": routes})
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
var dr apiv2.DeviceRoutes
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dr))
return dr
}
// One route: enabled reflects it; advertised stays empty (nothing announced).
dr := setRoutes([]string{"10.0.0.0/24"})
assert.Contains(t, dr.Enabled, "10.0.0.0/24")
assert.Empty(t, dr.Advertised)
assert.Contains(t, getDeviceRoutes(t, e.api, e.deviceID).Enabled, "10.0.0.0/24")
assert.Contains(t, approvedRouteStrings(e.srvNode(t)), "10.0.0.0/24")
assert.Empty(t, e.srvNode(t).AnnouncedRoutes())
// Two routes.
setRoutes([]string{"10.0.0.0/24", "192.168.0.0/24"})
approved := approvedRouteStrings(e.srvNode(t))
assert.Contains(t, approved, "10.0.0.0/24")
assert.Contains(t, approved, "192.168.0.0/24")
// Exit route expands to both families.
setRoutes([]string{"0.0.0.0/0"})
approved = approvedRouteStrings(e.srvNode(t))
assert.Contains(t, approved, "0.0.0.0/0")
assert.Contains(t, approved, "::/0")
// Clear.
dr = setRoutes([]string{})
assert.Empty(t, dr.Enabled)
assert.Empty(t, approvedRouteStrings(e.srvNode(t)))
// Malformed prefix is rejected; routes unchanged.
bad := e.api.Post("/api/v2/device/"+e.deviceID+"/routes", map[string]any{"routes": []string{"not-a-cidr"}})
assert.Equal(t, http.StatusBadRequest, bad.Code)
assert.Empty(t, approvedRouteStrings(e.srvNode(t)))
}
func TestAPIv2Device_SetAuthorized(t *testing.T) {
e := newDeviceTestEnv(t)
require.Equal(t, http.StatusOK,
e.api.Post("/api/v2/device/"+e.deviceID+"/authorized", map[string]any{"authorized": true}).Code)
assert.True(t, getDevice(t, e.api, e.deviceID).Authorized)
// De-authorize is rejected; the node stays present and authorized.
bad := e.api.Post("/api/v2/device/"+e.deviceID+"/authorized", map[string]any{"authorized": false})
assert.Equal(t, http.StatusBadRequest, bad.Code)
assert.Contains(t, bad.Body.String(), `"message"`)
assert.True(t, getDevice(t, e.api, e.deviceID).Authorized)
}
func TestAPIv2Device_Delete(t *testing.T) {
e := newDeviceTestEnv(t)
require.Equal(t, http.StatusOK, e.api.Delete("/api/v2/device/"+e.deviceID).Code)
assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/device/"+e.deviceID).Code)
_, ok := e.app.state.GetNodeByID(e.nodeID)
assert.False(t, ok, "node gone from the NodeStore")
// Delete again is 404.
assert.Equal(t, http.StatusNotFound, e.api.Delete("/api/v2/device/"+e.deviceID).Code)
}
func containsDeviceID(devs []apiv2.Device, id string) bool {
for _, d := range devs {
if d.ID == id || d.NodeID == id {
return true
}
}
return false
}
+312
View File
@@ -0,0 +1,312 @@
package hscontrol
import (
"encoding/json"
"net/http"
"strconv"
"testing"
"time"
"github.com/danielgtaylor/huma/v2/humatest"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// taggedCaps builds capabilities for a tagged auth key.
func taggedCaps(tags ...string) apiv2.KeyCapabilities {
return apiv2.KeyCapabilities{
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{
Reusable: true,
Preauthorized: true,
Tags: tags,
}},
}
}
// newKeyTestAPI builds an app + v2 API with NO owner user in context. Without the
// auth middleware ownerUser(ctx) is empty, so only the tagged-key path creates;
// the user-owned path (which needs an owning API key) is covered by TestAPIv2.
func newKeyTestAPI(t *testing.T) (*Headscale, humatest.TestAPI) {
t.Helper()
app := createTestApp(t)
_, api := humatest.New(t, apiv2.Config())
apiv2.Register(api, apiv2.Backend{State: app.state})
return app, api
}
// createKey POSTs a key and decodes the create response.
func createKey(t *testing.T, api humatest.TestAPI, req apiv2.CreateKeyRequest) apiv2.Key {
t.Helper()
resp := api.Post("/api/v2/tailnet/-/keys", req)
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
var key apiv2.Key
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &key))
return key
}
// getKey GETs a key by id and decodes it.
func getKey(t *testing.T, api humatest.TestAPI, id string) apiv2.Key {
t.Helper()
resp := api.Get("/api/v2/tailnet/-/keys/" + id)
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
var key apiv2.Key
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &key))
return key
}
// listKeys GETs the key list and returns the enveloped slice.
func listKeys(t *testing.T, api humatest.TestAPI) []apiv2.Key {
t.Helper()
resp := api.Get("/api/v2/tailnet/-/keys")
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
var list struct {
Keys []apiv2.Key `json:"keys"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
return list.Keys
}
func containsKeyID(keys []apiv2.Key, id string) bool {
for _, k := range keys {
if k.ID == id {
return true
}
}
return false
}
// srvKey is the server-side ground truth: the stored PreAuthKey, found by its
// stringified id through ListPreAuthKeys (User is preloaded).
func srvKey(t *testing.T, app *Headscale, id string) types.PreAuthKey {
t.Helper()
want, err := strconv.ParseUint(id, 10, 64)
require.NoError(t, err)
keys, err := app.state.ListPreAuthKeys()
require.NoError(t, err)
for _, k := range keys {
if k.ID == want {
return k
}
}
t.Fatalf("pre-auth key %s not found server-side", id)
return types.PreAuthKey{}
}
func keyCount(t *testing.T, app *Headscale) int {
t.Helper()
keys, err := app.state.ListPreAuthKeys()
require.NoError(t, err)
return len(keys)
}
func TestAPIv2Key_Create_Tagged(t *testing.T) {
app, api := newKeyTestAPI(t)
created := createKey(t, api, apiv2.CreateKeyRequest{
Description: "dev access",
ExpirySeconds: 86400,
Capabilities: taggedCaps("tag:test"),
})
// (a) create response — Tailscale Key shape, exact seconds (int64 wire).
assert.Equal(t, "auth", created.KeyType)
assert.NotEmpty(t, created.ID)
assert.NotEmpty(t, created.Key, "secret returned on create")
assert.Equal(t, "dev access", created.Description)
assert.Equal(t, int64(86400), created.ExpirySeconds, "seconds, not nanoseconds")
assert.Empty(t, created.UserID, "tagged key presents no owner")
assert.Equal(t, []string{"tag:test"}, created.Capabilities.Devices.Create.Tags)
assert.True(t, created.Capabilities.Devices.Create.Reusable)
assert.True(t, created.Capabilities.Devices.Create.Preauthorized, "echoed on create")
assert.NotNil(t, created.Expires)
assert.False(t, created.Invalid)
// (c) server-side — the stored key.
pak := srvKey(t, app, created.ID)
assert.True(t, pak.Reusable)
assert.False(t, pak.Ephemeral)
assert.Equal(t, []string{"tag:test"}, pak.Tags)
assert.Equal(t, "dev access", pak.Description)
assert.Nil(t, pak.User, "tagged key has no owning user")
require.NotNil(t, pak.CreatedAt)
require.NotNil(t, pak.Expiration)
assert.InDelta(t, 86400, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 2)
}
func TestAPIv2Key_Create_Permutations(t *testing.T) {
tests := []struct {
name string
req apiv2.CreateKeyRequest
wantReusable bool
wantEphemeral bool
wantDesc string
wantSeconds float64
}{
{
name: "single-use",
req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{Tags: []string{"tag:test"}}},
}},
wantSeconds: 7776000,
},
{
name: "reusable",
req: apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")},
wantReusable: true,
wantSeconds: 7776000,
},
{
name: "ephemeral",
req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{
Ephemeral: true,
Tags: []string{"tag:test"},
}},
}},
wantEphemeral: true,
wantSeconds: 7776000,
},
{
name: "with description",
req: apiv2.CreateKeyRequest{Description: "ci", Capabilities: taggedCaps("tag:test")},
wantReusable: true,
wantDesc: "ci",
wantSeconds: 7776000,
},
{
name: "explicit expiry",
req: apiv2.CreateKeyRequest{ExpirySeconds: 3600, Capabilities: taggedCaps("tag:test")},
wantReusable: true,
wantSeconds: 3600,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app, api := newKeyTestAPI(t)
created := createKey(t, api, tt.req)
assert.Equal(t, tt.wantReusable, created.Capabilities.Devices.Create.Reusable)
assert.Equal(t, tt.wantEphemeral, created.Capabilities.Devices.Create.Ephemeral)
assert.Equal(t, tt.wantDesc, created.Description)
assert.InDelta(t, tt.wantSeconds, float64(created.ExpirySeconds), 2)
pak := srvKey(t, app, created.ID)
assert.Equal(t, tt.wantReusable, pak.Reusable)
assert.Equal(t, tt.wantEphemeral, pak.Ephemeral)
assert.Equal(t, tt.wantDesc, pak.Description)
require.NotNil(t, pak.CreatedAt)
require.NotNil(t, pak.Expiration)
assert.InDelta(t, tt.wantSeconds, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 2)
})
}
}
func TestAPIv2Key_Get(t *testing.T) {
_, api := newKeyTestAPI(t)
created := createKey(t, api, apiv2.CreateKeyRequest{
Description: "dev access",
ExpirySeconds: 86400,
Capabilities: taggedCaps("tag:test"),
})
got := getKey(t, api, created.ID)
assert.Equal(t, created.ID, got.ID)
assert.Empty(t, got.Key, "secret omitted on get")
assert.Equal(t, "dev access", got.Description)
assert.Equal(t, int64(86400), got.ExpirySeconds, "stable across get")
assert.True(t, got.Capabilities.Devices.Create.Reusable)
assert.True(t, got.Capabilities.Devices.Create.Preauthorized, "Headscale always preauthorizes")
assert.Equal(t, []string{"tag:test"}, got.Capabilities.Devices.Create.Tags)
assert.False(t, got.Invalid)
// Unknown id and bad tailnet both 404 with the Tailscale error body.
assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/-/keys/999999").Code)
bad := api.Get("/api/v2/tailnet/example.com/keys/" + created.ID)
assert.Equal(t, http.StatusNotFound, bad.Code)
assert.Contains(t, bad.Body.String(), `"message"`)
}
func TestAPIv2Key_List(t *testing.T) {
app, api := newKeyTestAPI(t)
k1 := createKey(t, api, apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")})
k2 := createKey(t, api, apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")})
keys := listKeys(t, api)
assert.True(t, containsKeyID(keys, k1.ID))
assert.True(t, containsKeyID(keys, k2.ID))
// Server-side has both too.
assert.GreaterOrEqual(t, keyCount(t, app), 2)
bad := api.Get("/api/v2/tailnet/example.com/keys")
assert.Equal(t, http.StatusNotFound, bad.Code)
assert.Contains(t, bad.Body.String(), `"message"`)
}
func TestAPIv2Key_Delete(t *testing.T) {
app, api := newKeyTestAPI(t)
created := createKey(t, api, apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")})
require.Equal(t, http.StatusOK, api.Delete("/api/v2/tailnet/-/keys/"+created.ID).Code)
// DELETE soft-revokes (Tailscale-faithful): the key stays retrievable with
// invalid set and a revoked timestamp, and is still listed.
got := getKey(t, api, created.ID)
assert.True(t, got.Invalid, "revoked key reports invalid")
assert.NotNil(t, got.Revoked, "revoked timestamp is populated")
assert.True(t, containsKeyID(listKeys(t, api), created.ID), "revoked key still listed")
// Server-side: row kept, revoked stamped.
pak := srvKey(t, app, created.ID)
require.NotNil(t, pak.Revoked)
require.Error(t, pak.Validate(), "revoked key is invalid")
// Delete again -> 404 (already revoked).
assert.Equal(t, http.StatusNotFound, api.Delete("/api/v2/tailnet/-/keys/"+created.ID).Code)
// The collector hard-deletes keys revoked before the cutoff.
reaped, err := app.state.DestroyRevokedPreAuthKeysBefore(time.Now().Add(time.Minute))
require.NoError(t, err)
assert.GreaterOrEqual(t, reaped, 1)
assert.False(t, containsKeyID(listKeys(t, api), created.ID), "collector removed the revoked key")
assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/-/keys/"+created.ID).Code)
}
func TestAPIv2Key_Create_NoTags_NoOwner_400(t *testing.T) {
app, api := newKeyTestAPI(t)
before := keyCount(t, app)
resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{}})
assert.Equal(t, http.StatusBadRequest, resp.Code)
assert.Contains(t, resp.Body.String(), `"message"`)
// No orphan key was created.
assert.Equal(t, before, keyCount(t, app))
}
+146
View File
@@ -0,0 +1,146 @@
package hscontrol
import (
"encoding/json"
"net/http"
"testing"
"time"
"github.com/danielgtaylor/huma/v2/humatest"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// settingsAPIWithConfig registers the v2 API over a Backend carrying cfg and a
// zero State — the settings GET reads only b.Cfg, so the other handlers (which
// would need State) are simply never hit.
func settingsAPIWithConfig(t *testing.T, cfg *types.Config) humatest.TestAPI {
t.Helper()
_, api := humatest.New(t, apiv2.Config())
apiv2.Register(api, apiv2.Backend{Cfg: cfg})
return api
}
func getSettings(t *testing.T, api humatest.TestAPI) apiv2.TailnetSettings {
t.Helper()
resp := api.Get("/api/v2/tailnet/-/settings")
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
var s apiv2.TailnetSettings
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &s))
return s
}
// TestAPIv2SettingsComputedFields validates the GET mapping for each field that
// is computed from config — the branches the default-config roundtrip never
// exercises. Expectations live in struct fields, not name branches.
func TestAPIv2SettingsComputedFields(t *testing.T) {
tests := []struct {
name string
cfg types.Config
wantHTTPS bool
wantKeyDurationDays int
wantACLsExtManaged bool
}{
{
name: "defaults",
cfg: types.Config{},
},
{
name: "https via cert path",
cfg: types.Config{TLS: types.TLSConfig{CertPath: "/x/cert.pem"}},
wantHTTPS: true,
},
{
name: "https via letsencrypt",
cfg: types.Config{
TLS: types.TLSConfig{LetsEncrypt: types.LetsEncryptConfig{Hostname: "hs.example.com"}},
},
wantHTTPS: true,
},
{
name: "key duration 7d",
cfg: types.Config{Node: types.NodeConfig{Expiry: 7 * 24 * time.Hour}},
wantKeyDurationDays: 7,
},
{
name: "key duration 90d",
cfg: types.Config{Node: types.NodeConfig{Expiry: 90 * 24 * time.Hour}},
wantKeyDurationDays: 90,
},
{
name: "key duration truncates",
cfg: types.Config{Node: types.NodeConfig{Expiry: 36 * time.Hour}},
wantKeyDurationDays: 1,
},
{
name: "acls externally managed in file mode",
cfg: types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeFile}},
wantACLsExtManaged: true,
},
{
name: "acls db mode",
cfg: types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeDB}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := getSettings(t, settingsAPIWithConfig(t, &tt.cfg))
assert.Equal(t, tt.wantHTTPS, s.HTTPSEnabled)
assert.Equal(t, tt.wantKeyDurationDays, s.DevicesKeyDurationDays)
assert.Equal(t, tt.wantACLsExtManaged, s.ACLsExternallyManagedOn)
assert.Equal(t, "none", s.UsersRoleAllowedToJoinExternalTailnets)
})
}
}
// TestAPIv2SettingsConstantOffFields pins the honestly-hardcoded-off fields:
// even with every config knob set, they must not pick up signal.
func TestAPIv2SettingsConstantOffFields(t *testing.T) {
cfg := types.Config{
TLS: types.TLSConfig{CertPath: "/x/cert.pem"},
Node: types.NodeConfig{Expiry: 90 * 24 * time.Hour},
Policy: types.PolicyConfig{Mode: types.PolicyModeFile},
}
s := getSettings(t, settingsAPIWithConfig(t, &cfg))
assert.False(t, s.DevicesApprovalOn)
assert.False(t, s.DevicesAutoUpdatesOn)
assert.False(t, s.UsersApprovalOn)
assert.False(t, s.NetworkFlowLoggingOn)
assert.False(t, s.RegionalRoutingOn)
assert.False(t, s.PostureIdentityCollectionOn)
assert.Empty(t, s.ACLsExternalLink)
}
// TestAPIv2SettingsPatchUnsupported confirms writes are rejected and inert.
func TestAPIv2SettingsPatchUnsupported(t *testing.T) {
app := createTestApp(t)
api := registerAPIV2(t, app)
patch := api.Patch("/api/v2/tailnet/-/settings", map[string]any{"devicesApprovalOn": true})
assert.Equal(t, http.StatusNotImplemented, patch.Code)
assert.Contains(t, patch.Body.String(), `"message"`)
// The rejected PATCH did not mutate anything.
assert.False(t, getSettings(t, api).DevicesApprovalOn)
}
// TestAPIv2SettingsNonDefaultTailnet404 — the tailnet check runs before the
// 501, so a bad tailnet is 404 on both verbs.
func TestAPIv2SettingsNonDefaultTailnet404(t *testing.T) {
api := settingsAPIWithConfig(t, &types.Config{})
assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/example.com/settings").Code)
assert.Equal(t, http.StatusNotFound,
api.Patch("/api/v2/tailnet/example.com/settings", map[string]any{}).Code)
}