From 13ffd958ed4fa836be88227d7b56af417eda991d Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH] 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. --- hscontrol/apiv2_acl_test.go | 239 +++++++++++++++++++++ hscontrol/apiv2_devices_test.go | 343 +++++++++++++++++++++++++++++++ hscontrol/apiv2_keys_test.go | 312 ++++++++++++++++++++++++++++ hscontrol/apiv2_settings_test.go | 146 +++++++++++++ 4 files changed, 1040 insertions(+) create mode 100644 hscontrol/apiv2_acl_test.go create mode 100644 hscontrol/apiv2_devices_test.go create mode 100644 hscontrol/apiv2_keys_test.go create mode 100644 hscontrol/apiv2_settings_test.go diff --git a/hscontrol/apiv2_acl_test.go b/hscontrol/apiv2_acl_test.go new file mode 100644 index 00000000..def9af82 --- /dev/null +++ b/hscontrol/apiv2_acl_test.go @@ -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)) +} diff --git a/hscontrol/apiv2_devices_test.go b/hscontrol/apiv2_devices_test.go new file mode 100644 index 00000000..24c0d77a --- /dev/null +++ b/hscontrol/apiv2_devices_test.go @@ -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 +} diff --git a/hscontrol/apiv2_keys_test.go b/hscontrol/apiv2_keys_test.go new file mode 100644 index 00000000..679eb40f --- /dev/null +++ b/hscontrol/apiv2_keys_test.go @@ -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)) +} diff --git a/hscontrol/apiv2_settings_test.go b/hscontrol/apiv2_settings_test.go new file mode 100644 index 00000000..a46e83d7 --- /dev/null +++ b/hscontrol/apiv2_settings_test.go @@ -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) +}