mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-17 16:36:02 +00:00
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.
212 lines
6.5 KiB
Go
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)
|
|
})
|
|
}
|