mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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"])
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 404,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 200,
|
||||
"body": {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 200,
|
||||
"body": {}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 404,
|
||||
"body": null
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"status": 200,
|
||||
"body": {
|
||||
"apiKeys": []
|
||||
}
|
||||
}
|
||||
Vendored
+14
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"status": 200,
|
||||
"body": {
|
||||
"apiKeys": [
|
||||
{
|
||||
"createdAt": "\u003ctimestamp\u003e",
|
||||
"expiration": "\u003ctimestamp\u003e",
|
||||
"id": "1",
|
||||
"lastSeen": null,
|
||||
"prefix": "\u003csecret\u003e"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 404,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 404,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 404,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 404,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -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": {}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"status": 200,
|
||||
"body": {
|
||||
"changes": []
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -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
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 404,
|
||||
"body": null
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 404,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 400,
|
||||
"body": null
|
||||
}
|
||||
+4
@@ -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
|
||||
}
|
||||
+4
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user