mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 09:00:22 +09:00
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:
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user