mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-16 13:00:44 +09:00
151 lines
4.8 KiB
Go
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)
|
|
}
|