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)
}
}