a73x

internal/server/config/load_test.go

Ref:   Size: 7.7 KiB   History

package config

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// kekHex is a valid key_encryption_key: 64 hex characters.
const kekHex = "2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b"

// minimal is a config that passes every validation rule.
const minimal = `{
	"host_secret": "s3cret",
	"key_encryption_key": "` + kekHex + `",
	"advertise_http": "http://192.0.2.1:8080",
	"advertise_quic": "192.0.2.1:8443",
	"oidc": {
		"issuer": "http://127.0.0.1:9111",
		"client_id": "eitri-console",
		"public_url": "http://192.0.2.1:8080"
	}
}`

func write(t *testing.T, content string) string {
	t.Helper()
	p := filepath.Join(t.TempDir(), "server.json")
	if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
		t.Fatal(err)
	}
	return p
}

func TestLoadValid(t *testing.T) {
	cfg, err := Load(write(t, minimal))
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if cfg.HostSecret != "s3cret" || cfg.OIDC.ClientID != "eitri-console" {
		t.Errorf("fields not decoded: %+v", cfg)
	}
}

func TestLoadFileErrors(t *testing.T) {
	if _, err := Load(filepath.Join(t.TempDir(), "absent.json")); err == nil {
		t.Error("missing file must error")
	}
	if _, err := Load(write(t, "{nope")); err == nil || !strings.Contains(err.Error(), "parse config") {
		t.Error("malformed json must error with parse context")
	}
}

func TestLoadRequiresHostSecret(t *testing.T) {
	_, err := Load(write(t, strings.Replace(minimal, `"host_secret": "s3cret",`, "", 1)))
	if err == nil || !strings.Contains(err.Error(), "host_secret") {
		t.Errorf("got %v", err)
	}
}

// TestLoadValidatesTheKEK: the key that seals the fleet's own SSH CA is checked
// at boot, so a plane that could not open the key material it holds refuses to
// start rather than discovering it on some later request.
func TestLoadValidatesTheKEK(t *testing.T) {
	for _, tc := range []struct {
		name, kek string
		ok        bool
	}{
		{"64 hex chars", kekHex, true},
		{"uppercase hex", strings.ToUpper(kekHex), true},
		{"missing", "", false},
		{"too short", kekHex[:62], false},
		{"too long", kekHex + "ab", false},
		{"odd length", kekHex[:63], false},
		{"not hex", strings.Repeat("z", 64), false},
		{"a passphrase", "correct horse battery staple correct horse battery staple xxxxxx", false},
	} {
		t.Run(tc.name, func(t *testing.T) {
			cfg, err := Load(write(t, strings.Replace(minimal, kekHex, tc.kek, 1)))
			if !tc.ok {
				if err == nil || !strings.Contains(err.Error(), "key_encryption_key") {
					t.Fatalf("want a rejection naming key_encryption_key, got %v", err)
				}
				if strings.Contains(err.Error(), tc.kek) && tc.kek != "" {
					t.Errorf("the error echoes the key: %v", err)
				}
				return
			}
			if err != nil {
				t.Fatalf("valid kek rejected: %v", err)
			}
			kek, err := cfg.KEKBytes()
			if err != nil || len(kek) != 32 {
				t.Fatalf("KEKBytes: %v (%d bytes, want 32)", err, len(kek))
			}
		})
	}
}

// TestLoadNamesEveryMissingOIDCKey pins that the operator sees all missing
// oidc keys in ONE error, not one restart per key.
func TestLoadNamesEveryMissingOIDCKey(t *testing.T) {
	_, err := Load(write(t, `{"host_secret": "s", "key_encryption_key": "`+kekHex+`", "advertise_http": "h", "advertise_quic": "q"}`))
	if err == nil {
		t.Fatal("empty oidc block accepted")
	}
	for _, want := range []string{"oidc.issuer", "oidc.client_id", "oidc.public_url"} {
		if !strings.Contains(err.Error(), want) {
			t.Errorf("error %q must name %q", err, want)
		}
	}
}

// TestLoadRejectsMalformedPublicURL pins the boot-time URL check: public_url
// builds the OIDC callback, so a bare host, missing scheme, or non-http(s)
// scheme fails at startup rather than at the first redirect.
func TestLoadRejectsMalformedPublicURL(t *testing.T) {
	for _, bad := range []string{"192.0.2.1:8080", "example.com", "http://", "ftp://x.example", "/just/a/path"} {
		_, err := Load(write(t, strings.Replace(minimal, `"public_url": "http://192.0.2.1:8080"`, `"public_url": "`+bad+`"`, 1)))
		if err == nil || !strings.Contains(err.Error(), "public_url") {
			t.Errorf("public_url %q: got %v, want rejection", bad, err)
		}
	}
	for _, good := range []string{"http://192.0.2.1:8080", "https://eitri.example.com"} {
		if _, err := Load(write(t, strings.Replace(minimal, `"public_url": "http://192.0.2.1:8080"`, `"public_url": "`+good+`"`, 1))); err != nil {
			t.Errorf("public_url %q: unexpected %v", good, err)
		}
	}
}

func TestLoadRequiresAdvertiseAddrs(t *testing.T) {
	_, err := Load(write(t, strings.Replace(minimal, `"advertise_quic": "192.0.2.1:8443",`, "", 1)))
	if err == nil || !strings.Contains(err.Error(), "advertise_http and advertise_quic") {
		t.Errorf("got %v", err)
	}
}

// withImages splices a default_images block into the minimal config.
func withImages(body string) string {
	return strings.Replace(minimal, `"host_secret": "s3cret",`,
		`"host_secret": "s3cret", "default_images": {`+body+`},`, 1)
}

func TestLoadValidatesDefaultImages(t *testing.T) {
	good := strings.Repeat("a", 64)
	for _, tc := range []struct {
		name, body, want string // want=="" ⇒ must load cleanly
	}{
		{"one arch", `"amd64": {"url": "http://i", "sha256": "` + good + `"}`, ""},
		{"both arches", `"amd64": {"url": "http://i", "sha256": "` + good + `"},
			"arm64": {"url": "http://j", "sha256": "` + good + `"}`, ""},
		{"absent entirely", "", ""},
		{"malformed sha", `"arm64": {"url": "http://i", "sha256": "NOTHEX"}`, `default_images["arm64"].sha256`},
		{"missing url", `"amd64": {"sha256": "` + good + `"}`, `default_images["amd64"].url`},
		{"empty arch key", `"": {"url": "http://i", "sha256": "` + good + `"}`, "empty architecture key"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			_, err := Load(write(t, withImages(tc.body)))
			if tc.want == "" {
				if err != nil {
					t.Fatalf("valid config rejected: %v", err)
				}
				return
			}
			if err == nil || !strings.Contains(err.Error(), tc.want) {
				t.Fatalf("want error containing %q, got %v", tc.want, err)
			}
		})
	}
}

// TestLoadToleratesRetiredDefaultImageKeys pins that the old single-image keys
// warn but never fail: a config upgrade must not stop the control plane
// booting. They are ignored, so a one-click create for an unlisted arch fails
// at create time instead of handing a host an image it cannot execute.
func TestLoadToleratesRetiredDefaultImageKeys(t *testing.T) {
	old := strings.Replace(minimal, `"host_secret": "s3cret",`,
		`"host_secret": "s3cret", "default_image_url": "http://i",
		 "default_image_sha256": "`+strings.Repeat("a", 64)+`",`, 1)
	cfg, err := Load(write(t, old))
	if err != nil {
		t.Fatalf("retired keys must not fail the load: %v", err)
	}
	if len(cfg.DefaultImages) != 0 {
		t.Errorf("retired keys must not populate DefaultImages, got %v", cfg.DefaultImages)
	}
}

// TestLoadToleratesRetiredAdminToken pins that a stale admin_token key warns
// but does not fail — old configs keep booting.
func TestLoadToleratesRetiredAdminToken(t *testing.T) {
	withTok := strings.Replace(minimal, `"host_secret": "s3cret",`,
		`"host_secret": "s3cret", "admin_token": "stale",`, 1)
	if _, err := Load(write(t, withTok)); err != nil {
		t.Errorf("admin_token must warn, not fail: %v", err)
	}
}

func TestParseDuration(t *testing.T) {
	// Empty keeps the default.
	d, err := ParseDuration("knob", "", 90*time.Hour, nil, "")
	if err != nil || d != 90*time.Hour {
		t.Errorf("empty: %v %v", d, err)
	}
	d, err = ParseDuration("knob", "2h", 0, nil, "")
	if err != nil || d != 2*time.Hour {
		t.Errorf("2h: %v %v", d, err)
	}
	if _, err := ParseDuration("knob", "banana", 0, nil, ""); err == nil || !strings.Contains(err.Error(), "knob invalid") {
		t.Errorf("malformed: %v", err)
	}
	// The range rule is enforced and named in the error.
	if _, err := ParseDuration("knob", "-1h", 0, func(d time.Duration) bool { return d >= 0 }, ">= 0"); err == nil || !strings.Contains(err.Error(), ">= 0") {
		t.Errorf("range: %v", err)
	}
}