Files
headscale/hscontrol/apiv1_policy_test.go
T
Kristoffer Dalby f24fad2ccc state: install a written policy only after it is stored
Writers validated by installing the policy live, so a store failure
left a refused or unstored policy enforced. Validate on a throwaway
manager, store, then reload from the stored row.
2026-10-27 21:32:32 +00:00

237 lines
6.6 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("persist failure keeps live policy", func(t *testing.T) {
h := newAPIV1Harness(t)
body, err := json.Marshal(map[string]string{"policy": `{"acls":[]}`})
require.NoError(t, err)
require.Equal(t, http.StatusOK,
h.callHuma(http.MethodPut, "/api/v1/policy", body).status)
before, _ := h.app.state.Filter()
require.NoError(t, h.app.state.DB().DB.
Exec("ALTER TABLE policies RENAME TO policies_away").Error)
body, err = json.Marshal(map[string]string{"policy": validPolicy})
require.NoError(t, err)
res := h.callHuma(http.MethodPut, "/api/v1/policy", body)
assertStatus(t, res, http.StatusInternalServerError)
after, _ := h.app.state.Filter()
assert.Equal(t, before, after, "an unstored policy must not be enforced")
})
t.Run("empty rejected", func(t *testing.T) {
h := newAPIV1Harness(t)
body, err := json.Marshal(map[string]string{"policy": validPolicy})
require.NoError(t, err)
require.Equal(t, http.StatusOK,
h.callHuma(http.MethodPut, "/api/v1/policy", body).status)
for _, body := range []string{`{"policy":""}`, `{}`} {
res := h.callHuma(http.MethodPut, "/api/v1/policy", []byte(body))
assertStatus(t, res, http.StatusBadRequest)
}
stored, err := h.app.state.GetPolicy()
require.NoError(t, err)
assert.JSONEq(t, validPolicy, stored.Data)
})
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("empty rejected", func(t *testing.T) {
h := newAPIV1Harness(t)
res := h.callHuma(
http.MethodPost, "/api/v1/policy/check", []byte(`{"policy":""}`),
)
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
}