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.
186 lines
5.1 KiB
Go
186 lines
5.1 KiB
Go
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
|
|
}
|