headscale/hscontrol/apiv1_authmw_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

110 lines
3.4 KiB
Go

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