headscale/hscontrol/apiv1_apikeys_test.go
Kristoffer Dalby 1572ac551d 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.
2026-06-19 15:21:00 +02:00

212 lines
6.5 KiB
Go

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)
})
}