Files
headscale/hscontrol/apiv2_settings_test.go
2026-06-24 18:41:06 +02:00

151 lines
4.8 KiB
Go

package hscontrol
import (
"encoding/json"
"net/http"
"testing"
"time"
"github.com/danielgtaylor/huma/v2/humatest"
"github.com/google/go-cmp/cmp"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// settingsAPIWithConfig registers the v2 API over a Backend carrying cfg and a
// zero State — the settings GET reads only b.Cfg, so the other handlers (which
// would need State) are simply never hit.
func settingsAPIWithConfig(t *testing.T, cfg *types.Config) humatest.TestAPI {
t.Helper()
_, api := humatest.New(t, apiv2.Config())
apiv2.Register(api, apiv2.Backend{Cfg: cfg})
return api
}
func getSettings(t *testing.T, api humatest.TestAPI) apiv2.TailnetSettings {
t.Helper()
resp := api.Get("/api/v2/tailnet/-/settings")
require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body)
var s apiv2.TailnetSettings
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &s))
return s
}
// TestAPIv2SettingsComputedFields validates the GET mapping for each field that
// is computed from config — the branches the default-config roundtrip never
// exercises. Expectations live in struct fields, not name branches.
func TestAPIv2SettingsComputedFields(t *testing.T) {
tests := []struct {
name string
cfg types.Config
wantHTTPS bool
wantKeyDurationDays int
wantACLsExtManaged bool
}{
{
name: "defaults",
cfg: types.Config{},
},
{
name: "https via cert path",
cfg: types.Config{TLS: types.TLSConfig{CertPath: "/x/cert.pem"}},
wantHTTPS: true,
},
{
name: "https via letsencrypt",
cfg: types.Config{
TLS: types.TLSConfig{LetsEncrypt: types.LetsEncryptConfig{Hostname: "hs.example.com"}},
},
wantHTTPS: true,
},
{
name: "key duration 7d",
cfg: types.Config{Node: types.NodeConfig{Expiry: 7 * 24 * time.Hour}},
wantKeyDurationDays: 7,
},
{
name: "key duration 90d",
cfg: types.Config{Node: types.NodeConfig{Expiry: 90 * 24 * time.Hour}},
wantKeyDurationDays: 90,
},
{
name: "key duration truncates",
cfg: types.Config{Node: types.NodeConfig{Expiry: 36 * time.Hour}},
wantKeyDurationDays: 1,
},
{
name: "acls externally managed in file mode",
cfg: types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeFile}},
wantACLsExtManaged: true,
},
{
name: "acls db mode",
cfg: types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeDB}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := getSettings(t, settingsAPIWithConfig(t, &tt.cfg))
assert.Equal(t, tt.wantHTTPS, s.HTTPSEnabled)
assert.Equal(t, tt.wantKeyDurationDays, s.DevicesKeyDurationDays)
assert.Equal(t, tt.wantACLsExtManaged, s.ACLsExternallyManagedOn)
assert.Equal(t, "none", s.UsersRoleAllowedToJoinExternalTailnets)
})
}
}
// TestAPIv2SettingsConstantOffFields pins the hardcoded-off fields:
// even with every config knob set, they must not pick up signal.
func TestAPIv2SettingsConstantOffFields(t *testing.T) {
cfg := types.Config{
TLS: types.TLSConfig{CertPath: "/x/cert.pem"},
Node: types.NodeConfig{Expiry: 90 * 24 * time.Hour},
Policy: types.PolicyConfig{Mode: types.PolicyModeFile},
}
s := getSettings(t, settingsAPIWithConfig(t, &cfg))
// Only the four computed fields reflect cfg; everything else stays off.
want := apiv2.TailnetSettings{
ACLsExternallyManagedOn: true,
DevicesKeyDurationDays: 90,
HTTPSEnabled: true,
UsersRoleAllowedToJoinExternalTailnets: "none",
}
if diff := cmp.Diff(want, s); diff != "" {
t.Errorf("settings mismatch (-want +got):\n%s", diff)
}
}
// TestAPIv2SettingsPatchUnsupported confirms writes are rejected and inert.
func TestAPIv2SettingsPatchUnsupported(t *testing.T) {
app := createTestApp(t)
api := registerAPIV2(t, app)
patch := api.Patch("/api/v2/tailnet/-/settings", map[string]any{"devicesApprovalOn": true})
assert.Equal(t, http.StatusNotImplemented, patch.Code)
assert.Contains(t, patch.Body.String(), `"message"`)
// The rejected PATCH did not mutate anything.
assert.False(t, getSettings(t, api).DevicesApprovalOn)
}
// TestAPIv2SettingsNonDefaultTailnet404 — the tailnet check runs before the
// 501, so a bad tailnet is 404 on both verbs.
func TestAPIv2SettingsNonDefaultTailnet404(t *testing.T) {
api := settingsAPIWithConfig(t, &types.Config{})
assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/example.com/settings").Code)
assert.Equal(t, http.StatusNotFound,
api.Patch("/api/v2/tailnet/example.com/settings", map[string]any{}).Code)
}