test: add golden parity tests for the v1 API

Pin every endpoint's behaviour with golden fixtures captured from the
retiring grpc-gateway (timestamps and secrets neutralised). Error cases
assert their HTTP status independently of the golden via assertStatus, so
a blind regenerate cannot re-pin a wrong code; HEADSCALE_UPDATE_GOLDEN
rewrites the fixtures. A unit test exercises the API-key auth middleware
on the real server router. Exclude the emitted spec and goldens from
pre-commit checks.
This commit is contained in:
Kristoffer Dalby
2026-06-19 06:12:57 +00:00
parent c9bbb5bdec
commit 1572ac551d
90 changed files with 2495 additions and 2 deletions
+3 -2
View File
@@ -2,8 +2,9 @@
# See: https://prek.j178.dev/quickstart/
# See: https://prek.j178.dev/builtin/
# Global exclusions - ignore generated code
exclude: ^gen/
# Global exclusions - ignore generated code (proto output and emitted OpenAPI)
# and recorded golden fixtures.
exclude: ^(gen|openapi)/|^hscontrol/testdata/apiv1_golden/
repos:
# Built-in hooks from pre-commit/pre-commit-hooks
+212
View File
@@ -0,0 +1,212 @@
package hscontrol
import (
"encoding/json"
"net/http"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// seedAPIKey creates a single API key and returns its database ID and the
// masked display prefix the API exposes, so tests can address it by either.
func seedAPIKey(t *testing.T, app *Headscale) (uint64, string) {
t.Helper()
_, key, err := app.state.CreateAPIKey(nil)
require.NoError(t, err)
return key.ID, "hskey-api-" + key.Prefix + "-***"
}
func TestAPIV1ApiKeyCreate(t *testing.T) {
t.Run("huma response shape", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.callHuma(http.MethodPost, "/api/v1/apikey", []byte(`{}`))
require.Equal(t, http.StatusOK, res.status)
var got struct {
APIKey string `json:"apiKey"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
// The full secret is returned exactly once, on creation.
assert.NotEmpty(t, got.APIKey)
assert.Contains(t, got.APIKey, "hskey-api-")
})
t.Run("creates secret", func(t *testing.T) {
// The secret is random, so it can't be captured in a golden; just assert
// success and that a secret is returned.
humaApp := createTestApp(t)
hum := callHandler(newHumaTestHandler(humaApp), http.MethodPost, "/api/v1/apikey", []byte(`{}`))
assert.Equal(t, http.StatusOK, hum.status)
var humBody struct {
APIKey string `json:"apiKey"`
}
require.NoError(t, json.Unmarshal(hum.body, &humBody))
assert.NotEmpty(t, humBody.APIKey)
})
}
func TestAPIV1ApiKeyList(t *testing.T) {
t.Run("empty returns empty array", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.callHuma(http.MethodGet, "/api/v1/apikey", nil)
require.Equal(t, http.StatusOK, res.status)
assert.JSONEq(t, `{"apiKeys":[]}`, string(res.body))
})
t.Run("empty parity", func(t *testing.T) {
h := newAPIV1Harness(t)
h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil)
})
t.Run("populated parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedAPIKey(t, h.app)
seedAPIKey(t, h.app)
h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil)
})
t.Run("nil timestamps emitted as null parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedAPIKey(t, h.app)
// Nil expiration: lastSeen and expiration are unset and must emit as
// JSON null.
res := h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil)
var got struct {
APIKeys []map[string]any `json:"apiKeys"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
require.Len(t, got.APIKeys, 1)
assert.Nil(t, got.APIKeys[0]["lastSeen"])
assert.Nil(t, got.APIKeys[0]["expiration"])
assert.Contains(t, got.APIKeys[0], "createdAt")
assert.Contains(t, got.APIKeys[0]["prefix"], "hskey-api-")
})
t.Run("zero expiration emitted as zero instant parity", func(t *testing.T) {
h := newAPIV1Harness(t)
// Non-nil zero expiration (as gRPC CreateApiKey does for a missing one)
// must render as the zero instant, not null.
var zero time.Time
_, _, err := h.app.state.CreateAPIKey(&zero)
require.NoError(t, err)
res := h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil)
var got struct {
APIKeys []map[string]any `json:"apiKeys"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
require.Len(t, got.APIKeys, 1)
assert.Equal(t, "0001-01-01T00:00:00Z", got.APIKeys[0]["expiration"])
})
}
func TestAPIV1ApiKeyExpire(t *testing.T) {
t.Run("by id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
id, _ := seedAPIKey(t, h.app)
h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire",
[]byte(`{"id":"`+strconv.FormatUint(id, 10)+`"}`))
})
t.Run("by prefix parity", func(t *testing.T) {
h := newAPIV1Harness(t)
_, prefix := seedAPIKey(t, h.app)
h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire",
[]byte(`{"prefix":"`+prefix+`"}`))
})
t.Run("neither id nor prefix parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", []byte(`{}`))
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("both id and prefix parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire",
[]byte(`{"id":"1","prefix":"abc"}`))
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("not found by id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", []byte(`{"id":"999"}`))
assertStatus(t, res, http.StatusNotFound)
})
t.Run("expires the key", func(t *testing.T) {
h := newAPIV1Harness(t)
id, _ := seedAPIKey(t, h.app)
res := h.callHuma(http.MethodPost, "/api/v1/apikey/expire",
[]byte(`{"id":"`+strconv.FormatUint(id, 10)+`"}`))
require.Equal(t, http.StatusOK, res.status)
listRes := h.callHuma(http.MethodGet, "/api/v1/apikey", nil)
var got struct {
APIKeys []struct {
Expiration *time.Time `json:"expiration"`
} `json:"apiKeys"`
}
require.NoError(t, json.Unmarshal(listRes.body, &got))
require.Len(t, got.APIKeys, 1)
require.NotNil(t, got.APIKeys[0].Expiration)
assert.False(t, got.APIKeys[0].Expiration.IsZero(), "expiration should be set")
})
}
func TestAPIV1ApiKeyDelete(t *testing.T) {
t.Run("by prefix deletes the key", func(t *testing.T) {
h := newAPIV1Harness(t)
_, prefix := seedAPIKey(t, h.app)
res := h.callHuma(http.MethodDelete, "/api/v1/apikey/"+prefix, nil)
require.Equal(t, http.StatusOK, res.status)
assert.JSONEq(t, `{}`, string(res.body))
listRes := h.callHuma(http.MethodGet, "/api/v1/apikey", nil)
assert.JSONEq(t, `{"apiKeys":[]}`, string(listRes.body))
})
t.Run("path prefix with id query is both -> 400 parity", func(t *testing.T) {
h := newAPIV1Harness(t)
id, prefix := seedAPIKey(t, h.app)
res := h.assertParity(t, http.MethodDelete,
"/api/v1/apikey/"+prefix+"?id="+strconv.FormatUint(id, 10), nil)
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("not found by prefix parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/doesnotexist", nil)
assertStatus(t, res, http.StatusNotFound)
})
t.Run("both id and prefix parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/abc?id=1", nil)
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/abc?id=notanumber", nil)
assertStatus(t, res, http.StatusBadRequest)
})
}
+187
View File
@@ -0,0 +1,187 @@
package hscontrol
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// seedAuthRequest stores a pending registration auth cache entry under authID so
// an AuthRegister/Approve/Reject call has a real session to act on. Capturing
// the keys/authID at the call site keeps isolated apps identical.
func seedAuthRequest(
user string,
authID types.AuthID,
machineKey key.MachinePublic,
nodeKey key.NodePublic,
discoKey key.DiscoPublic,
hostname string,
) func(t *testing.T, app *Headscale) {
return func(t *testing.T, app *Headscale) {
t.Helper()
app.state.CreateUserForTest(user)
regData := &types.RegistrationData{
MachineKey: machineKey,
NodeKey: nodeKey,
DiscoKey: discoKey,
Hostname: hostname,
Hostinfo: &tailcfg.Hostinfo{Hostname: hostname},
}
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData))
}
}
func TestAPIV1AuthRegister(t *testing.T) {
t.Run("happy path parity", func(t *testing.T) {
// Capture keys/authID once so both isolated apps register the identical
// node and produce byte-equal bodies.
authID := types.MustAuthID()
machineKey := key.NewMachine().Public()
nodeKey := key.NewNode().Public()
discoKey := key.NewDisco().Public()
seed := seedAuthRequest("alice", authID, machineKey, nodeKey, discoKey, "regnode")
body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, authID.String())
assertParityIsolated(t, seed, http.MethodPost, "/api/v1/auth/register", body)
})
t.Run("happy path response shape", func(t *testing.T) {
h := newAPIV1Harness(t)
authID := types.MustAuthID()
seedAuthRequest(
"alice", authID,
key.NewMachine().Public(),
key.NewNode().Public(),
key.NewDisco().Public(),
"regnode",
)(t, h.app)
body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, authID.String())
res := h.callHuma(http.MethodPost, "/api/v1/auth/register", body)
require.Equal(t, http.StatusOK, res.status)
var got struct {
Node map[string]any `json:"node"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.Equal(t, "1", got.Node["id"])
assert.Equal(t, "REGISTER_METHOD_CLI", got.Node["registerMethod"])
// EmitUnpopulated parity: an unset expiry (we passed none) is null, an
// unset embedded pre-auth key is null, and repeated fields are []
// rather than omitted.
assert.Contains(t, got.Node, "expiry")
assert.Nil(t, got.Node["expiry"])
assert.Contains(t, got.Node, "preAuthKey")
assert.Nil(t, got.Node["preAuthKey"])
assert.Equal(t, []any{}, got.Node["subnetRoutes"])
assert.NotNil(t, got.Node["user"])
})
// A malformed auth_id is bad input (400), matching AuthApprove/AuthReject.
t.Run("invalid auth_id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedUsers("alice")(t, h.app)
res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register",
[]byte(`{"user":"alice","authId":"not-a-valid-auth-id"}`))
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("unknown user parity", func(t *testing.T) {
h := newAPIV1Harness(t)
body := fmt.Appendf(nil, `{"user":"ghost","authId":%q}`, types.MustAuthID().String())
res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", body)
assertStatus(t, res, http.StatusNotFound)
})
// Valid auth_id but no cached session: HandleNodeFromAuthPath returns
// ErrNodeNotFoundRegistrationCache, which maps to 404.
t.Run("no pending session parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedUsers("alice")(t, h.app)
body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, types.MustAuthID().String())
res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", body)
assertStatus(t, res, http.StatusNotFound)
})
}
func TestAPIV1AuthApprove(t *testing.T) {
t.Run("happy path", func(t *testing.T) {
h := newAPIV1Harness(t)
authID := types.MustAuthID()
authReq := types.NewAuthRequest()
h.app.state.SetAuthCacheEntry(authID, authReq)
body := fmt.Appendf(nil, `{"authId":%q}`, authID.String())
res := h.callHuma(http.MethodPost, "/api/v1/auth/approve", body)
require.Equal(t, http.StatusOK, res.status)
assert.JSONEq(t, `{}`, string(res.body))
verdict := <-authReq.WaitForAuth()
assert.True(t, verdict.Accept(), "approve must finish the session with a passing verdict")
})
// Malformed auth_id: AuthApprove returns codes.InvalidArgument → 400.
t.Run("invalid auth_id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/auth/approve",
[]byte(`{"authId":"not-a-valid-auth-id"}`))
assertStatus(t, res, http.StatusBadRequest)
})
// Well-formed auth_id with no pending session: codes.NotFound → 404.
t.Run("no pending session parity", func(t *testing.T) {
h := newAPIV1Harness(t)
body := fmt.Appendf(nil, `{"authId":%q}`, types.MustAuthID().String())
res := h.assertParity(t, http.MethodPost, "/api/v1/auth/approve", body)
assertStatus(t, res, http.StatusNotFound)
})
}
func TestAPIV1AuthReject(t *testing.T) {
t.Run("happy path", func(t *testing.T) {
h := newAPIV1Harness(t)
authID := types.MustAuthID()
authReq := types.NewAuthRequest()
h.app.state.SetAuthCacheEntry(authID, authReq)
body := fmt.Appendf(nil, `{"authId":%q}`, authID.String())
res := h.callHuma(http.MethodPost, "/api/v1/auth/reject", body)
require.Equal(t, http.StatusOK, res.status)
assert.JSONEq(t, `{}`, string(res.body))
verdict := <-authReq.WaitForAuth()
assert.False(t, verdict.Accept(), "reject must finish the session with a failing verdict")
})
t.Run("invalid auth_id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/auth/reject",
[]byte(`{"authId":"not-a-valid-auth-id"}`))
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("no pending session parity", func(t *testing.T) {
h := newAPIV1Harness(t)
body := fmt.Appendf(nil, `{"authId":%q}`, types.MustAuthID().String())
res := h.assertParity(t, http.MethodPost, "/api/v1/auth/reject", body)
assertStatus(t, res, http.StatusNotFound)
})
}
+110
View File
@@ -0,0 +1,110 @@
package hscontrol
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAPIV1AuthMiddleware proves the v1 API, mounted on the real router, is
// guarded by the Huma security middleware: missing or bad Bearer tokens are
// rejected with 401 before reaching a handler; a valid key passes to 200. The
// per-endpoint harness bypasses auth via WithLocalTrust, so this is the one
// place the middleware itself is exercised.
func TestAPIV1AuthMiddleware(t *testing.T) {
app := createTestApp(t)
handler := app.HTTPHandler()
expiry := time.Now().Add(time.Hour)
valid, _, err := app.state.CreateAPIKey(&expiry)
require.NoError(t, err)
tests := []struct {
name string
authHeader string
wantStatus int
}{
{name: "missing bearer", authHeader: "", wantStatus: http.StatusUnauthorized},
{name: "no bearer prefix", authHeader: "tskey-invalid", wantStatus: http.StatusUnauthorized},
{name: "invalid bearer token", authHeader: "Bearer tskey-invalid", wantStatus: http.StatusUnauthorized},
{name: "valid api key", authHeader: "Bearer " + valid, wantStatus: http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/api/v1/node", nil,
)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equalf(t, tt.wantStatus, rec.Code, "body: %s", rec.Body.String())
})
}
}
// TestAPIV1DocsArePublic proves the OpenAPI document and docs UI live under
// /api/v1 and are reachable without an API key, while the operations beside them
// stay key-gated. The docs page points at the versioned spec so a future
// /api/v2 can carry its own.
func TestAPIV1DocsArePublic(t *testing.T) {
app := createTestApp(t)
handler := app.HTTPHandler()
tests := []struct {
path string
contains string
}{
{path: "/api/v1/openapi.yaml", contains: "openapi:"},
{path: "/api/v1/docs", contains: "/api/v1/openapi.yaml"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
// No Authorization header: these must be public.
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, tt.path, nil,
)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equalf(t, http.StatusOK, rec.Code, "%s should be public; body: %s", tt.path, rec.Body.String())
assert.Containsf(t, rec.Body.String(), tt.contains, "%s body", tt.path)
})
}
}
// TestAPIV1Unauthorized401 pins the 401 body: RFC 7807 JSON containing
// "Unauthorized", under 100 bytes and leaking no data, as the integration
// auth-bypass tests require.
func TestAPIV1Unauthorized401(t *testing.T) {
app := createTestApp(t)
handler := app.HTTPHandler()
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/api/v1/node", nil,
)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusUnauthorized, rec.Code)
body := rec.Body.Bytes()
assert.Contains(t, string(body), "Unauthorized")
assert.Lessf(t, len(body), 100, "401 body must stay small (no data leak): %s", body)
var problem map[string]any
require.NoError(t, json.Unmarshal(body, &problem), "401 body must be JSON: %s", body)
assert.NotContains(t, problem, "users")
}
+276
View File
@@ -0,0 +1,276 @@
package hscontrol
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
apiv1 "github.com/juanfont/headscale/hscontrol/api/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// apiV1Harness drives a request through the Huma service and compares the result
// against a golden keyed by the test name. Goldens neutralise timestamps so they
// stay stable across runs; refresh with HEADSCALE_UPDATE_GOLDEN=1 after an
// intentional contract change. assertParity suits reads/errors; mutations use
// assertParityIsolated against a freshly-seeded app.
type apiV1Harness struct {
app *Headscale
huma http.Handler
}
func newAPIV1Harness(t *testing.T) *apiV1Harness {
t.Helper()
app := createTestApp(t)
return &apiV1Harness{
app: app,
huma: newHumaTestHandler(app),
}
}
// newHumaTestHandler wraps the Huma handler in WithLocalTrust to bypass the
// bearer-key middleware — the same local-trust the unix socket gets — so these
// tests can exercise response shapes. Auth itself is covered against the full
// router in TestAPIV1AuthMiddleware.
func newHumaTestHandler(app *Headscale) http.Handler {
mux, _ := apiv1.Handler(apiv1.Backend{
State: app.state,
Change: app.Change,
Cfg: app.cfg,
})
return apiv1.WithLocalTrust(mux)
}
// httpResult is one HTTP exchange captured for comparison.
type httpResult struct {
status int
body []byte
}
func callHandler(handler http.Handler, method, path string, body []byte) httpResult {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req := httptest.NewRequestWithContext(context.Background(), method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return httpResult{status: rec.Code, body: rec.Body.Bytes()}
}
func (h *apiV1Harness) callHuma(method, path string, body []byte) httpResult {
return callHandler(h.huma, method, path, body)
}
// assertParity asserts the Huma response matches the golden: equal status, and
// for success an equal JSON body (timestamps neutralised). Error bodies are not
// compared — the shape deliberately deviates from legacy
// ({code,message,details} vs Huma RFC7807).
func (h *apiV1Harness) assertParity(t *testing.T, method, path string, body []byte) httpResult {
t.Helper()
hum := h.callHuma(method, path, body)
assertAgainstGolden(t, method, path, hum)
return hum
}
// assertParityIsolated runs the request against a fresh app seeded by seed (may
// be nil) and compares to the golden. Use for mutations.
func assertParityIsolated(
t *testing.T,
seed func(t *testing.T, app *Headscale),
method, path string,
body []byte,
) httpResult {
t.Helper()
humaApp := createTestApp(t)
if seed != nil {
seed(t, humaApp)
}
hum := callHandler(newHumaTestHandler(humaApp), method, path, body)
assertAgainstGolden(t, method, path, hum)
return hum
}
// assertStatus pins the exact HTTP status independently of the golden, so a
// regression fails even if the golden is blindly regenerated.
func assertStatus(t *testing.T, got httpResult, want int) {
t.Helper()
assert.Equalf(t, want, got.status,
"unexpected HTTP status (golden-independent guard); body: %s", got.body)
}
func isSuccess(status int) bool {
return status >= 200 && status < 300
}
const goldenDir = "testdata/apiv1_golden"
// goldenRecord is the persisted shape of a captured response: the HTTP status
// and, when present, the normalised JSON body.
type goldenRecord struct {
Status int `json:"status"`
Body json.RawMessage `json:"body"`
}
func goldenPath(t *testing.T) string {
t.Helper()
name := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
return filepath.Join(goldenDir, name+".json")
}
// assertAgainstGolden compares the Huma response to the recorded golden: status
// always, body only for success responses.
func assertAgainstGolden(t *testing.T, method, path string, hum httpResult) {
t.Helper()
gpath := goldenPath(t)
if os.Getenv("HEADSCALE_UPDATE_GOLDEN") != "" {
writeGolden(t, gpath, hum)
return
}
raw, err := os.ReadFile(gpath)
require.NoErrorf(t, err,
"missing golden %s for %s %s — run with HEADSCALE_UPDATE_GOLDEN=1 to generate",
gpath, method, path)
var golden goldenRecord
require.NoErrorf(t, json.Unmarshal(raw, &golden), "decoding golden %s", gpath)
assert.Equalf(t, golden.Status, hum.status,
"status mismatch for %s %s\nhuma body: %s", method, path, hum.body)
if isSuccess(golden.Status) {
var goldenBody any
if len(golden.Body) > 0 {
goldenBody = normalizeJSON(t, golden.Body, true)
}
assert.Equalf(t, goldenBody, normalizeJSON(t, hum.body, true),
"body mismatch for %s %s", method, path)
}
}
// writeGolden persists the response under HEADSCALE_UPDATE_GOLDEN. Success
// bodies are normalised the same way the comparison path does, so the file stays
// stable across runs. Error responses are stored status-only, since the reader
// never compares error bodies.
func writeGolden(t *testing.T, gpath string, hum httpResult) {
t.Helper()
rec := goldenRecord{Status: hum.status}
if isSuccess(hum.status) {
if normalised := normalizeJSON(t, hum.body, true); normalised != nil {
raw, err := json.Marshal(normalised)
require.NoErrorf(t, err, "marshalling golden body for %s", gpath)
rec.Body = raw
}
}
out, err := json.MarshalIndent(rec, "", " ")
require.NoErrorf(t, err, "marshalling golden record for %s", gpath)
require.NoErrorf(t, os.MkdirAll(filepath.Dir(gpath), 0o755),
"creating golden dir for %s", gpath)
require.NoErrorf(t, os.WriteFile(gpath, append(out, '\n'), 0o600),
"writing golden %s", gpath)
t.Logf("updated golden %s", gpath)
}
// nonDeterministicRe matches values that embed per-app random material and so
// cannot match byte-for-byte across apps: masked key/secret prefixes
// (hskey-auth-<prefix>-***, hskey-api-<prefix>-***) and Tailscale key material
// (mkey:/nodekey:/discokey:<hex>). Neutralised to a sentinel when neutralise is
// true.
var nonDeterministicRe = regexp.MustCompile(
`^(hskey-(auth|api)-[0-9a-f]+-\*\*\*|(mkey|nodekey|discokey):[0-9a-f]+)$`,
)
// normalizeJSON decodes JSON for order-independent, type-aware comparison.
// Numbers decode as json.Number so "5" and 5 are not conflated, surfacing
// string-encoding differences. With neutralise, timestamps and per-app random
// values (see nonDeterministicRe) become sentinels; otherwise timestamps are
// canonicalised to a UTC instant.
func normalizeJSON(t *testing.T, b []byte, neutralise bool) any {
t.Helper()
if len(bytes.TrimSpace(b)) == 0 {
return nil
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var v any
require.NoErrorf(t, dec.Decode(&v), "decoding JSON: %s", b)
return canonicalizeTimestamps(v, neutralise)
}
func canonicalizeTimestamps(v any, neutralise bool) any {
switch val := v.(type) {
case map[string]any:
for k, child := range val {
val[k] = canonicalizeTimestamps(child, neutralise)
}
return val
case []any:
for i, child := range val {
val[i] = canonicalizeTimestamps(child, neutralise)
}
return val
case string:
if neutralise && nonDeterministicRe.MatchString(val) {
return "<secret>"
}
ts, err := time.Parse(time.RFC3339Nano, val)
if err != nil {
return val
}
if neutralise {
return "<timestamp>"
}
return ts.UTC().Format(time.RFC3339Nano)
default:
return v
}
}
+24
View File
@@ -0,0 +1,24 @@
package hscontrol
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIV1Health(t *testing.T) {
h := newAPIV1Harness(t)
t.Run("huma returns healthy with database connectivity", func(t *testing.T) {
res := h.callHuma(http.MethodGet, "/api/v1/health", nil)
assert.Equal(t, http.StatusOK, res.status)
require.JSONEq(t, `{"databaseConnectivity":true}`, string(res.body))
})
t.Run("parity with gateway", func(t *testing.T) {
h.assertParity(t, http.MethodGet, "/api/v1/health", nil)
})
}
+416
View File
@@ -0,0 +1,416 @@
package hscontrol
import (
"encoding/json"
"net/http"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// nodeSeed carries fixed key material so isolated apps in a mutation parity test
// register byte-identical nodes; otherwise random keys would defeat body
// comparison.
type nodeSeed struct {
user string
machineKey key.MachinePrivate
nodeKey key.NodePrivate
hostname string
tags []string
}
func newNodeSeed(user, hostname string, tags ...string) nodeSeed {
return nodeSeed{
user: user,
machineKey: key.NewMachine(),
nodeKey: key.NewNode(),
hostname: hostname,
tags: tags,
}
}
// register inserts the user, a matching pre-auth key, and the node itself using
// the seed's fixed keys, returning the created node ID.
func (s nodeSeed) register(t *testing.T, app *Headscale) types.NodeID {
t.Helper()
user := app.state.CreateUserForTest(s.user)
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, s.tags)
require.NoError(t, err)
req := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: s.nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: s.hostname},
}
_, err = app.handleRegisterWithAuthKey(req, s.machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(s.nodeKey.Public())
require.True(t, found)
return node.ID()
}
func seedNodes(seeds ...nodeSeed) func(t *testing.T, app *Headscale) {
return func(t *testing.T, app *Headscale) {
t.Helper()
for _, s := range seeds {
s.register(t, app)
}
}
}
func TestAPIV1NodeGet(t *testing.T) {
t.Run("happy parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil)
})
t.Run("tagged node parity", func(t *testing.T) {
h := newAPIV1Harness(t)
// No tagOwners means policy won't authorise a tag, so register via the
// pre-auth key path, which forces tags regardless of policy.
seedNodes(newNodeSeed("bob", "node-b", "tag:initial"))(t, h.app)
h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil)
})
t.Run("not found parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodGet, "/api/v1/node/99999", nil)
assertStatus(t, res, http.StatusNotFound)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodGet, "/api/v1/node/abc", nil)
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("huma response shape", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(newNodeSeed("carol", "node-c"))(t, h.app)
res := h.callHuma(http.MethodGet, "/api/v1/node/1", nil)
require.Equal(t, http.StatusOK, res.status)
var got struct {
Node map[string]any `json:"node"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.Equal(t, "1", got.Node["id"])
assert.Equal(t, "node-c", got.Node["name"])
assert.Equal(t, "REGISTER_METHOD_AUTH_KEY", got.Node["registerMethod"])
// EmitUnpopulated parity: slices present as [], expiry as null.
assert.Equal(t, []any{}, got.Node["ipAddresses"])
assert.Equal(t, []any{}, got.Node["tags"])
assert.Nil(t, got.Node["expiry"])
assert.Contains(t, got.Node, "user")
assert.Contains(t, got.Node, "preAuthKey")
assert.Contains(t, got.Node, "createdAt")
})
}
func TestAPIV1NodeList(t *testing.T) {
t.Run("empty returns empty array", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.callHuma(http.MethodGet, "/api/v1/node", nil)
require.Equal(t, http.StatusOK, res.status)
assert.JSONEq(t, `{"nodes":[]}`, string(res.body))
})
t.Run("empty parity", func(t *testing.T) {
h := newAPIV1Harness(t)
h.assertParity(t, http.MethodGet, "/api/v1/node", nil)
})
t.Run("all parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(
newNodeSeed("alice", "node-a"),
newNodeSeed("bob", "node-b"),
)(t, h.app)
h.assertParity(t, http.MethodGet, "/api/v1/node", nil)
})
t.Run("filter by user parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(
newNodeSeed("alice", "node-a"),
newNodeSeed("bob", "node-b"),
)(t, h.app)
h.assertParity(t, http.MethodGet, "/api/v1/node?user=alice", nil)
})
t.Run("unknown user parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
res := h.assertParity(t, http.MethodGet, "/api/v1/node?user=nope", nil)
assertStatus(t, res, http.StatusNotFound)
})
}
func TestAPIV1NodeDelete(t *testing.T) {
t.Run("happy parity", func(t *testing.T) {
assertParityIsolated(t, seedNodes(newNodeSeed("alice", "node-a")),
http.MethodDelete, "/api/v1/node/1", nil)
})
t.Run("not found parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodDelete, "/api/v1/node/99999", nil)
assertStatus(t, res, http.StatusNotFound)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodDelete, "/api/v1/node/abc", nil)
assertStatus(t, res, http.StatusBadRequest)
})
}
func TestAPIV1NodeExpire(t *testing.T) {
// The embedded pre-auth key's masked prefix is random per app, so isolated
// body comparison is impossible. GetNode/ListNodes prove full serialisation;
// here we just assert the mutation took effect.
t.Run("huma expires node", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
res := h.callHuma(http.MethodPost, "/api/v1/node/1/expire", nil)
require.Equal(t, http.StatusOK, res.status)
var got struct {
Node map[string]any `json:"node"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.Equal(t, "1", got.Node["id"])
// Expiry was nil before; expiring sets it to a concrete timestamp.
assert.NotNil(t, got.Node["expiry"])
})
t.Run("not found parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/expire", nil)
assertStatus(t, res, http.StatusNotFound)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/expire", nil)
assertStatus(t, res, http.StatusBadRequest)
})
}
func TestAPIV1NodeRename(t *testing.T) {
t.Run("huma renames node", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
res := h.callHuma(http.MethodPost, "/api/v1/node/1/rename/renamed-node", nil)
require.Equal(t, http.StatusOK, res.status)
var got struct {
Node map[string]any `json:"node"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.Equal(t, "renamed-node", got.Node["givenName"])
})
t.Run("not found parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/rename/whatever", nil)
assertStatus(t, res, http.StatusNotFound)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/rename/whatever", nil)
assertStatus(t, res, http.StatusBadRequest)
})
}
func TestAPIV1NodeSetTags(t *testing.T) {
t.Run("not found parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/tags",
[]byte(`{"tags":["tag:foo"]}`))
assertStatus(t, res, http.StatusNotFound)
})
t.Run("empty tags parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags", []byte(`{"tags":[]}`))
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("unauthorized tag parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
// No tagOwners in policy → SetNodeTags rejects with InvalidArgument (400).
res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags",
[]byte(`{"tags":["tag:server"]}`))
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/tags",
[]byte(`{"tags":["tag:foo"]}`))
assertStatus(t, res, http.StatusBadRequest)
})
}
func TestAPIV1NodeSetApprovedRoutes(t *testing.T) {
t.Run("huma sets approved routes", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
res := h.callHuma(http.MethodPost, "/api/v1/node/1/approve_routes",
[]byte(`{"routes":[]}`))
require.Equal(t, http.StatusOK, res.status)
var got struct {
Node map[string]any `json:"node"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.Equal(t, "1", got.Node["id"])
// SubnetRoutes is recomputed from primary routes; empty here but present.
assert.Equal(t, []any{}, got.Node["approvedRoutes"])
assert.Equal(t, []any{}, got.Node["subnetRoutes"])
})
t.Run("not found parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/approve_routes",
[]byte(`{"routes":["10.0.0.0/24"]}`))
assertStatus(t, res, http.StatusNotFound)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/approve_routes",
[]byte(`{"routes":[]}`))
assertStatus(t, res, http.StatusBadRequest)
})
}
func TestAPIV1NodeRegister(t *testing.T) {
t.Run("happy parity", func(t *testing.T) {
authID := types.MustAuthID()
mk := key.NewMachine()
nk := key.NewNode()
seed := func(t *testing.T, app *Headscale) {
t.Helper()
app.state.CreateUserForTest("alice")
regData := &types.RegistrationData{
NodeKey: nk.Public(),
MachineKey: mk.Public(),
Hostname: "registered-node",
}
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData))
}
assertParityIsolated(t, seed, http.MethodPost,
"/api/v1/node/register?user=alice&key="+authID.String(), nil)
})
t.Run("invalid key parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
res := h.assertParity(t, http.MethodPost,
"/api/v1/node/register?user=alice&key=invalidkey", nil)
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("unknown user parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost,
"/api/v1/node/register?user=nope&key="+types.MustAuthID().String(), nil)
assertStatus(t, res, http.StatusNotFound)
})
}
func TestAPIV1NodeBackfillIPs(t *testing.T) {
t.Run("confirmed empty parity", func(t *testing.T) {
assertParityIsolated(t, nil, http.MethodPost,
"/api/v1/node/backfillips?confirmed=true", nil)
})
t.Run("confirmed returns empty array", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.callHuma(http.MethodPost, "/api/v1/node/backfillips?confirmed=true", nil)
require.Equal(t, http.StatusOK, res.status)
assert.JSONEq(t, `{"changes":[]}`, string(res.body))
})
t.Run("unconfirmed parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/node/backfillips", nil)
assertStatus(t, res, http.StatusBadRequest)
})
}
func TestAPIV1NodeDebugCreate(t *testing.T) {
t.Run("unknown user parity", func(t *testing.T) {
h := newAPIV1Harness(t)
body := []byte(`{"user":"nope","key":"` + types.MustAuthID().String() +
`","name":"dbg","routes":[]}`)
res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body)
assertStatus(t, res, http.StatusNotFound)
})
t.Run("invalid key parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
body := []byte(`{"user":"alice","key":"badkey","name":"dbg","routes":[]}`)
res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body)
assertStatus(t, res, http.StatusBadRequest)
})
// The handler mints fresh key material per call, so isolated apps can't
// produce byte-identical bodies; assert the shape directly instead.
t.Run("huma response shape", func(t *testing.T) {
h := newAPIV1Harness(t)
h.app.state.CreateUserForTest("alice")
body := []byte(`{"user":"alice","key":"` + types.MustAuthID().String() +
`","name":"dbgnode","routes":["10.0.0.0/24"]}`)
res := h.callHuma(http.MethodPost, "/api/v1/debug/node", body)
require.Equal(t, http.StatusOK, res.status)
var got struct {
Node map[string]any `json:"node"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.Equal(t, "dbgnode", got.Node["name"])
assert.Equal(t, "REGISTER_METHOD_UNSPECIFIED", got.Node["registerMethod"])
assert.Equal(t, []any{"10.0.0.0/24"}, got.Node["availableRoutes"])
// The synthetic echo node has no pre-auth key: emitted as null.
assert.Nil(t, got.Node["preAuthKey"])
// Zero-time expiry/lastSeen are emitted as the zero instant, not null.
assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["expiry"])
assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["lastSeen"])
})
}
+186
View File
@@ -0,0 +1,186 @@
package hscontrol
import (
"encoding/json"
"net/http"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/mapper"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// validPolicy is a minimal policy that passes validation without nodes or users.
const validPolicy = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
// invalidPolicy is not valid HuJSON, so policy manager construction fails.
const invalidPolicy = `{"acls": [`
// seedPolicy stores a policy in the database so reads return it.
func seedPolicy(policy string) func(t *testing.T, app *Headscale) {
return func(t *testing.T, app *Headscale) {
t.Helper()
_, err := app.state.SetPolicyInDB(policy)
require.NoError(t, err)
}
}
func TestAPIV1PolicyGet(t *testing.T) {
t.Run("empty parity", func(t *testing.T) {
// With no policy stored, the DB load fails; this is treated as a server
// fault (500), matching the legacy contract.
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodGet, "/api/v1/policy", nil)
assertStatus(t, res, http.StatusInternalServerError)
})
t.Run("set parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedPolicy(validPolicy)(t, h.app)
h.assertParity(t, http.MethodGet, "/api/v1/policy", nil)
})
t.Run("huma response shape", func(t *testing.T) {
h := newAPIV1Harness(t)
seedPolicy(validPolicy)(t, h.app)
res := h.callHuma(http.MethodGet, "/api/v1/policy", nil)
require.Equal(t, http.StatusOK, res.status)
var got struct {
Policy string `json:"policy"`
UpdatedAt string `json:"updatedAt"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.JSONEq(t, validPolicy, got.Policy)
// updatedAt is emitted even though no omitempty is set.
assert.NotEmpty(t, got.UpdatedAt)
})
}
func TestAPIV1PolicySet(t *testing.T) {
t.Run("valid parity", func(t *testing.T) {
body, err := json.Marshal(map[string]string{"policy": validPolicy})
require.NoError(t, err)
assertParityIsolated(t, nil, http.MethodPut, "/api/v1/policy", body)
})
t.Run("invalid parity", func(t *testing.T) {
body, err := json.Marshal(map[string]string{"policy": invalidPolicy})
require.NoError(t, err)
res := assertParityIsolated(t, nil, http.MethodPut, "/api/v1/policy", body)
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("huma response shape", func(t *testing.T) {
h := newAPIV1Harness(t)
body, err := json.Marshal(map[string]string{"policy": validPolicy})
require.NoError(t, err)
res := h.callHuma(http.MethodPut, "/api/v1/policy", body)
require.Equal(t, http.StatusOK, res.status)
var got struct {
Policy string `json:"policy"`
UpdatedAt string `json:"updatedAt"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.JSONEq(t, validPolicy, got.Policy)
assert.NotEmpty(t, got.UpdatedAt)
})
t.Run("not-db mode rejected", func(t *testing.T) {
// Policy updates are only valid in DB mode; file mode rejects with 400.
// createTestApp is DB mode, so build a file-mode app here.
humaApp := createFilePolicyApp(t)
body, err := json.Marshal(map[string]string{"policy": validPolicy})
require.NoError(t, err)
hum := callHandler(newHumaTestHandler(humaApp), http.MethodPut, "/api/v1/policy", body)
assert.Equal(t, http.StatusBadRequest, hum.status,
"huma body: %s", hum.body)
})
}
func TestAPIV1PolicyCheck(t *testing.T) {
t.Run("valid parity", func(t *testing.T) {
h := newAPIV1Harness(t)
body, err := json.Marshal(map[string]string{"policy": validPolicy})
require.NoError(t, err)
h.assertParity(t, http.MethodPost, "/api/v1/policy/check", body)
})
t.Run("invalid parity", func(t *testing.T) {
h := newAPIV1Harness(t)
body, err := json.Marshal(map[string]string{"policy": invalidPolicy})
require.NoError(t, err)
res := h.assertParity(t, http.MethodPost, "/api/v1/policy/check", body)
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("valid returns empty object", func(t *testing.T) {
h := newAPIV1Harness(t)
body, err := json.Marshal(map[string]string{"policy": validPolicy})
require.NoError(t, err)
res := h.callHuma(http.MethodPost, "/api/v1/policy/check", body)
require.Equal(t, http.StatusOK, res.status)
assert.JSONEq(t, `{}`, string(res.body))
})
}
// createFilePolicyApp mirrors createTestApp but with file-based policy mode so
// the policy-update-disabled path can be exercised.
func createFilePolicyApp(t *testing.T) *Headscale {
t.Helper()
tmpDir := t.TempDir()
cfg := types.Config{
ServerURL: "http://localhost:8080",
NoisePrivateKeyPath: tmpDir + "/noise_private.key",
Database: types.DatabaseConfig{
Type: "sqlite3",
Sqlite: types.SqliteConfig{
Path: tmpDir + "/headscale_test.db",
},
},
OIDC: types.OIDCConfig{},
Policy: types.PolicyConfig{
Mode: types.PolicyModeFile,
},
Tuning: types.Tuning{
BatchChangeDelay: 100 * time.Millisecond,
BatcherWorkers: 1,
},
}
app, err := NewHeadscale(&cfg)
require.NoError(t, err)
app.mapBatcher = mapper.NewBatcherAndMapper(&cfg, app.state)
app.mapBatcher.Start()
t.Cleanup(func() {
if app.mapBatcher != nil {
app.mapBatcher.Close()
}
})
return app
}
+163
View File
@@ -0,0 +1,163 @@
package hscontrol
import (
"encoding/json"
"net/http"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// seedPreAuthKeys creates a user-owned key and a tagged (system-created) key;
// the tagged key exercises the user:null path.
func seedPreAuthKeys() func(t *testing.T, app *Headscale) {
return func(t *testing.T, app *Headscale) {
t.Helper()
user := app.state.CreateUserForTest("alice")
uid := types.UserID(user.ID)
exp := time.Time{}
_, err := app.state.CreatePreAuthKey(&uid, true, false, &exp, nil)
require.NoError(t, err)
// Tagged, system-created: no user.
_, err = app.state.CreatePreAuthKey(nil, false, true, &exp, []string{"tag:test"})
require.NoError(t, err)
}
}
func TestAPIV1CreatePreAuthKey(t *testing.T) {
t.Run("huma response shape", func(t *testing.T) {
h := newAPIV1Harness(t)
h.app.state.CreateUserForTest("alice")
res := h.callHuma(http.MethodPost, "/api/v1/preauthkey",
[]byte(`{"user":"1","reusable":true}`))
require.Equal(t, http.StatusOK, res.status)
var got struct {
PreAuthKey map[string]any `json:"preAuthKey"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.Equal(t, "1", got.PreAuthKey["id"])
assert.Equal(t, true, got.PreAuthKey["reusable"])
// Zero-value fields are emitted (EmitUnpopulated parity).
assert.Equal(t, false, got.PreAuthKey["ephemeral"])
assert.Equal(t, false, got.PreAuthKey["used"])
assert.Contains(t, got.PreAuthKey, "expiration")
assert.Contains(t, got.PreAuthKey, "createdAt")
// Empty tags serialize as [], not null.
assert.Equal(t, []any{}, got.PreAuthKey["aclTags"])
// The freshly created key returns its full secret.
assert.NotEmpty(t, got.PreAuthKey["key"])
user, ok := got.PreAuthKey["user"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "1", user["id"])
})
t.Run("tagged key has null user", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.callHuma(http.MethodPost, "/api/v1/preauthkey",
[]byte(`{"aclTags":["tag:test"]}`))
require.Equal(t, http.StatusOK, res.status)
var got struct {
PreAuthKey map[string]any `json:"preAuthKey"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.Nil(t, got.PreAuthKey["user"])
assert.Equal(t, []any{"tag:test"}, got.PreAuthKey["aclTags"])
})
t.Run("invalid tag parity", func(t *testing.T) {
h := newAPIV1Harness(t)
h.app.state.CreateUserForTest("alice")
res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey",
[]byte(`{"user":"1","aclTags":["badtag"]}`))
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("nonexistent user parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"user":"999"}`))
assertStatus(t, res, http.StatusNotFound)
})
t.Run("invalid user id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"user":"abc"}`))
assertStatus(t, res, http.StatusBadRequest)
})
t.Run("neither tagged nor owned parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"reusable":true}`))
assertStatus(t, res, http.StatusBadRequest)
})
}
func TestAPIV1ExpirePreAuthKey(t *testing.T) {
t.Run("parity", func(t *testing.T) {
assertParityIsolated(t, seedPreAuthKeys(), http.MethodPost,
"/api/v1/preauthkey/expire", []byte(`{"id":"1"}`))
})
t.Run("nonexistent parity", func(t *testing.T) {
res := assertParityIsolated(t, seedPreAuthKeys(), http.MethodPost,
"/api/v1/preauthkey/expire", []byte(`{"id":"99999"}`))
assertStatus(t, res, http.StatusNotFound)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey/expire", []byte(`{"id":"abc"}`))
assertStatus(t, res, http.StatusBadRequest)
})
}
func TestAPIV1DeletePreAuthKey(t *testing.T) {
t.Run("parity", func(t *testing.T) {
assertParityIsolated(t, seedPreAuthKeys(), http.MethodDelete,
"/api/v1/preauthkey?id=1", nil)
})
t.Run("nonexistent parity", func(t *testing.T) {
res := assertParityIsolated(t, seedPreAuthKeys(), http.MethodDelete,
"/api/v1/preauthkey?id=99999", nil)
assertStatus(t, res, http.StatusNotFound)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodDelete, "/api/v1/preauthkey?id=abc", nil)
assertStatus(t, res, http.StatusBadRequest)
})
}
func TestAPIV1ListPreAuthKeys(t *testing.T) {
t.Run("empty returns empty array", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.callHuma(http.MethodGet, "/api/v1/preauthkey", nil)
require.Equal(t, http.StatusOK, res.status)
assert.JSONEq(t, `{"preAuthKeys":[]}`, string(res.body))
})
t.Run("empty parity", func(t *testing.T) {
h := newAPIV1Harness(t)
h.assertParity(t, http.MethodGet, "/api/v1/preauthkey", nil)
})
t.Run("all parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedPreAuthKeys()(t, h.app)
h.assertParity(t, http.MethodGet, "/api/v1/preauthkey", nil)
})
}
+123
View File
@@ -0,0 +1,123 @@
package hscontrol
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func seedUsers(names ...string) func(t *testing.T, app *Headscale) {
return func(t *testing.T, app *Headscale) {
t.Helper()
for _, n := range names {
app.state.CreateUserForTest(n)
}
}
}
func TestAPIV1CreateUser(t *testing.T) {
t.Run("huma response shape", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.callHuma(http.MethodPost, "/api/v1/user", []byte(`{"name":"test"}`))
require.Equal(t, http.StatusOK, res.status)
var got struct {
User map[string]any `json:"user"`
}
require.NoError(t, json.Unmarshal(res.body, &got))
assert.Equal(t, "1", got.User["id"])
assert.Equal(t, "test", got.User["name"])
assert.Contains(t, got.User, "createdAt")
// Zero-value fields are emitted as empty strings (EmitUnpopulated parity).
assert.Empty(t, got.User["email"])
assert.Empty(t, got.User["displayName"])
})
t.Run("parity", func(t *testing.T) {
assertParityIsolated(t, nil, http.MethodPost, "/api/v1/user",
[]byte(`{"name":"test","displayName":"Test","email":"t@example.com"}`))
})
t.Run("duplicate name parity", func(t *testing.T) {
res := assertParityIsolated(t, seedUsers("dup"), http.MethodPost, "/api/v1/user",
[]byte(`{"name":"dup"}`))
assertStatus(t, res, http.StatusConflict)
})
}
func TestAPIV1RenameUser(t *testing.T) {
t.Run("parity", func(t *testing.T) {
assertParityIsolated(t, seedUsers("alice"), http.MethodPost,
"/api/v1/user/1/rename/bob", nil)
})
t.Run("nonexistent parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/user/999/rename/bob", nil)
assertStatus(t, res, http.StatusNotFound)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodPost, "/api/v1/user/abc/rename/bob", nil)
assertStatus(t, res, http.StatusBadRequest)
})
}
func TestAPIV1DeleteUser(t *testing.T) {
t.Run("parity", func(t *testing.T) {
assertParityIsolated(t, seedUsers("alice"), http.MethodDelete, "/api/v1/user/1", nil)
})
t.Run("nonexistent parity", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.assertParity(t, http.MethodDelete, "/api/v1/user/999", nil)
assertStatus(t, res, http.StatusNotFound)
})
}
func TestAPIV1ListUsers(t *testing.T) {
t.Run("empty returns empty array", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.callHuma(http.MethodGet, "/api/v1/user", nil)
require.Equal(t, http.StatusOK, res.status)
assert.JSONEq(t, `{"users":[]}`, string(res.body))
})
t.Run("empty parity", func(t *testing.T) {
h := newAPIV1Harness(t)
h.assertParity(t, http.MethodGet, "/api/v1/user", nil)
})
t.Run("all parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedUsers("alice", "bob", "carol")(t, h.app)
h.assertParity(t, http.MethodGet, "/api/v1/user", nil)
})
t.Run("filter by name parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedUsers("alice", "bob")(t, h.app)
h.assertParity(t, http.MethodGet, "/api/v1/user?name=alice", nil)
})
t.Run("filter by id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedUsers("alice", "bob")(t, h.app)
h.assertParity(t, http.MethodGet, "/api/v1/user?id=2", nil)
})
t.Run("invalid id parity", func(t *testing.T) {
h := newAPIV1Harness(t)
seedUsers("alice")(t, h.app)
res := h.assertParity(t, http.MethodGet, "/api/v1/user?id=abc", nil)
assertStatus(t, res, http.StatusBadRequest)
})
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 200,
"body": {}
}
@@ -0,0 +1,4 @@
{
"status": 200,
"body": {}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,6 @@
{
"status": 200,
"body": {
"apiKeys": []
}
}
@@ -0,0 +1,14 @@
{
"status": 200,
"body": {
"apiKeys": [
{
"createdAt": "\u003ctimestamp\u003e",
"expiration": null,
"id": "1",
"lastSeen": null,
"prefix": "\u003csecret\u003e"
}
]
}
}
@@ -0,0 +1,21 @@
{
"status": 200,
"body": {
"apiKeys": [
{
"createdAt": "\u003ctimestamp\u003e",
"expiration": null,
"id": "1",
"lastSeen": null,
"prefix": "\u003csecret\u003e"
},
{
"createdAt": "\u003ctimestamp\u003e",
"expiration": null,
"id": "2",
"lastSeen": null,
"prefix": "\u003csecret\u003e"
}
]
}
}
@@ -0,0 +1,14 @@
{
"status": 200,
"body": {
"apiKeys": [
{
"createdAt": "\u003ctimestamp\u003e",
"expiration": "\u003ctimestamp\u003e",
"id": "1",
"lastSeen": null,
"prefix": "\u003csecret\u003e"
}
]
}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,34 @@
{
"status": 200,
"body": {
"node": {
"approvedRoutes": [],
"availableRoutes": [],
"createdAt": "\u003ctimestamp\u003e",
"discoKey": "\u003csecret\u003e",
"expiry": null,
"givenName": "regnode",
"id": "1",
"ipAddresses": [],
"lastSeen": "\u003ctimestamp\u003e",
"machineKey": "\u003csecret\u003e",
"name": "regnode",
"nodeKey": "\u003csecret\u003e",
"online": false,
"preAuthKey": null,
"registerMethod": "REGISTER_METHOD_CLI",
"subnetRoutes": [],
"tags": [],
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
}
}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 409,
"body": null
}
@@ -0,0 +1,15 @@
{
"status": 200,
"body": {
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "Test",
"email": "t@example.com",
"id": "1",
"name": "test",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 200,
"body": {}
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 200,
"body": {}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 200,
"body": {}
}
@@ -0,0 +1,6 @@
{
"status": 200,
"body": {
"databaseConnectivity": true
}
}
@@ -0,0 +1,40 @@
{
"status": 200,
"body": {
"preAuthKeys": [
{
"aclTags": [],
"createdAt": "\u003ctimestamp\u003e",
"ephemeral": false,
"expiration": "\u003ctimestamp\u003e",
"id": "1",
"key": "\u003csecret\u003e",
"reusable": true,
"used": false,
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
},
{
"aclTags": [
"tag:test"
],
"createdAt": "\u003ctimestamp\u003e",
"ephemeral": true,
"expiration": "\u003ctimestamp\u003e",
"id": "2",
"key": "\u003csecret\u003e",
"reusable": false,
"used": false,
"user": null
}
]
}
}
@@ -0,0 +1,6 @@
{
"status": 200,
"body": {
"preAuthKeys": []
}
}
@@ -0,0 +1,37 @@
{
"status": 200,
"body": {
"users": [
{
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
},
{
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "2",
"name": "bob",
"profilePicUrl": "",
"provider": "",
"providerId": ""
},
{
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "3",
"name": "carol",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
]
}
}
@@ -0,0 +1,6 @@
{
"status": 200,
"body": {
"users": []
}
}
@@ -0,0 +1,17 @@
{
"status": 200,
"body": {
"users": [
{
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "2",
"name": "bob",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
]
}
}
@@ -0,0 +1,17 @@
{
"status": 200,
"body": {
"users": [
{
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
]
}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,6 @@
{
"status": 200,
"body": {
"changes": []
}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 200,
"body": {}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,53 @@
{
"status": 200,
"body": {
"node": {
"approvedRoutes": [],
"availableRoutes": [],
"createdAt": "\u003ctimestamp\u003e",
"discoKey": "\u003csecret\u003e",
"expiry": null,
"givenName": "node-a",
"id": "1",
"ipAddresses": [],
"lastSeen": "\u003ctimestamp\u003e",
"machineKey": "\u003csecret\u003e",
"name": "node-a",
"nodeKey": "\u003csecret\u003e",
"online": false,
"preAuthKey": {
"aclTags": [],
"createdAt": "\u003ctimestamp\u003e",
"ephemeral": false,
"expiration": null,
"id": "1",
"key": "\u003csecret\u003e",
"reusable": false,
"used": true,
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
},
"registerMethod": "REGISTER_METHOD_AUTH_KEY",
"subnetRoutes": [],
"tags": [],
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
}
}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,48 @@
{
"status": 200,
"body": {
"node": {
"approvedRoutes": [],
"availableRoutes": [],
"createdAt": "\u003ctimestamp\u003e",
"discoKey": "\u003csecret\u003e",
"expiry": null,
"givenName": "node-b",
"id": "1",
"ipAddresses": [],
"lastSeen": "\u003ctimestamp\u003e",
"machineKey": "\u003csecret\u003e",
"name": "node-b",
"nodeKey": "\u003csecret\u003e",
"online": false,
"preAuthKey": {
"aclTags": [
"tag:initial"
],
"createdAt": "\u003ctimestamp\u003e",
"ephemeral": false,
"expiration": null,
"id": "1",
"key": "\u003csecret\u003e",
"reusable": false,
"used": true,
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "bob",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
},
"registerMethod": "REGISTER_METHOD_AUTH_KEY",
"subnetRoutes": [],
"tags": [
"tag:initial"
],
"user": null
}
}
}
@@ -0,0 +1,103 @@
{
"status": 200,
"body": {
"nodes": [
{
"approvedRoutes": [],
"availableRoutes": [],
"createdAt": "\u003ctimestamp\u003e",
"discoKey": "\u003csecret\u003e",
"expiry": null,
"givenName": "node-a",
"id": "1",
"ipAddresses": [],
"lastSeen": "\u003ctimestamp\u003e",
"machineKey": "\u003csecret\u003e",
"name": "node-a",
"nodeKey": "\u003csecret\u003e",
"online": false,
"preAuthKey": {
"aclTags": [],
"createdAt": "\u003ctimestamp\u003e",
"ephemeral": false,
"expiration": null,
"id": "1",
"key": "\u003csecret\u003e",
"reusable": false,
"used": true,
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
},
"registerMethod": "REGISTER_METHOD_AUTH_KEY",
"subnetRoutes": [],
"tags": [],
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
},
{
"approvedRoutes": [],
"availableRoutes": [],
"createdAt": "\u003ctimestamp\u003e",
"discoKey": "\u003csecret\u003e",
"expiry": null,
"givenName": "node-b",
"id": "2",
"ipAddresses": [],
"lastSeen": "\u003ctimestamp\u003e",
"machineKey": "\u003csecret\u003e",
"name": "node-b",
"nodeKey": "\u003csecret\u003e",
"online": false,
"preAuthKey": {
"aclTags": [],
"createdAt": "\u003ctimestamp\u003e",
"ephemeral": false,
"expiration": null,
"id": "2",
"key": "\u003csecret\u003e",
"reusable": false,
"used": true,
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "2",
"name": "bob",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
},
"registerMethod": "REGISTER_METHOD_AUTH_KEY",
"subnetRoutes": [],
"tags": [],
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "2",
"name": "bob",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
}
]
}
}
@@ -0,0 +1,6 @@
{
"status": 200,
"body": {
"nodes": []
}
}
@@ -0,0 +1,55 @@
{
"status": 200,
"body": {
"nodes": [
{
"approvedRoutes": [],
"availableRoutes": [],
"createdAt": "\u003ctimestamp\u003e",
"discoKey": "\u003csecret\u003e",
"expiry": null,
"givenName": "node-a",
"id": "1",
"ipAddresses": [],
"lastSeen": "\u003ctimestamp\u003e",
"machineKey": "\u003csecret\u003e",
"name": "node-a",
"nodeKey": "\u003csecret\u003e",
"online": false,
"preAuthKey": {
"aclTags": [],
"createdAt": "\u003ctimestamp\u003e",
"ephemeral": false,
"expiration": null,
"id": "1",
"key": "\u003csecret\u003e",
"reusable": false,
"used": true,
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
},
"registerMethod": "REGISTER_METHOD_AUTH_KEY",
"subnetRoutes": [],
"tags": [],
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
}
]
}
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,34 @@
{
"status": 200,
"body": {
"node": {
"approvedRoutes": [],
"availableRoutes": [],
"createdAt": "\u003ctimestamp\u003e",
"discoKey": "\u003csecret\u003e",
"expiry": null,
"givenName": "registered-node",
"id": "1",
"ipAddresses": [],
"lastSeen": "\u003ctimestamp\u003e",
"machineKey": "\u003csecret\u003e",
"name": "registered-node",
"nodeKey": "\u003csecret\u003e",
"online": false,
"preAuthKey": null,
"registerMethod": "REGISTER_METHOD_CLI",
"subnetRoutes": [],
"tags": [],
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "alice",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
}
}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 200,
"body": {}
}
@@ -0,0 +1,4 @@
{
"status": 500,
"body": null
}
@@ -0,0 +1,7 @@
{
"status": 200,
"body": {
"policy": "{\"acls\":[{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}]}",
"updatedAt": "\u003ctimestamp\u003e"
}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,7 @@
{
"status": 200,
"body": {
"policy": "{\"acls\":[{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}]}",
"updatedAt": "\u003ctimestamp\u003e"
}
}
@@ -0,0 +1,4 @@
{
"status": 400,
"body": null
}
@@ -0,0 +1,4 @@
{
"status": 404,
"body": null
}
@@ -0,0 +1,15 @@
{
"status": 200,
"body": {
"user": {
"createdAt": "\u003ctimestamp\u003e",
"displayName": "",
"email": "",
"id": "1",
"name": "bob",
"profilePicUrl": "",
"provider": "",
"providerId": ""
}
}
}