a73x

internal/smoke/config_test.go

Ref:   Size: 9.5 KiB   History

package smoke

import (
	"strings"
	"testing"

	"github.com/a73x/eitri/internal/guest"
)

// fakeGetenv returns a getenv func backed by a map, so tests never touch the
// real process environment.
func fakeGetenv(vals map[string]string) func(string) string {
	return func(key string) string { return vals[key] }
}

func requiredVals() map[string]string {
	return map[string]string{
		"SERVER_URL":       "https://server.example:8443",
		"CI_USER":          "ci@eitri.local",
		"CI_PASSWORD_FILE": "/etc/eitri/ci-password",
		"CI_PAT_FILE":      "/etc/eitri/ci-pat",
		"AGENT_HOSTS":      "ubuntu@10.0.0.5:2222",
	}
}

func TestLoadConfigParsesAgentHostWithPort(t *testing.T) {
	cfg, err := loadConfig(fakeGetenv(requiredVals()))
	if err != nil {
		t.Fatalf("loadConfig: %v", err)
	}
	if cfg.AgentUserHost != "ubuntu@10.0.0.5" {
		t.Errorf("AgentUserHost = %q, want ubuntu@10.0.0.5", cfg.AgentUserHost)
	}
	if cfg.AgentPort != 2222 {
		t.Errorf("AgentPort = %d, want 2222", cfg.AgentPort)
	}
}

func TestLoadConfigDefaultsPortWithoutColon(t *testing.T) {
	vals := requiredVals()
	vals["AGENT_HOSTS"] = "ubuntu@10.0.0.5"
	cfg, err := loadConfig(fakeGetenv(vals))
	if err != nil {
		t.Fatalf("loadConfig: %v", err)
	}
	if cfg.AgentUserHost != "ubuntu@10.0.0.5" {
		t.Errorf("AgentUserHost = %q, want ubuntu@10.0.0.5", cfg.AgentUserHost)
	}
	if cfg.AgentPort != 22 {
		t.Errorf("AgentPort = %d, want 22", cfg.AgentPort)
	}
}

func TestLoadConfigMultiEntryTakesFirst(t *testing.T) {
	vals := requiredVals()
	vals["AGENT_HOSTS"] = "ubuntu@10.0.0.5:2200 ubuntu@10.0.0.6:2201"
	cfg, err := loadConfig(fakeGetenv(vals))
	if err != nil {
		t.Fatalf("loadConfig: %v", err)
	}
	if cfg.AgentUserHost != "ubuntu@10.0.0.5" || cfg.AgentPort != 2200 {
		t.Errorf("got %q:%d, want ubuntu@10.0.0.5:2200", cfg.AgentUserHost, cfg.AgentPort)
	}
}

func TestLoadConfigMissingRequiredVars(t *testing.T) {
	cases := []struct {
		name    string
		unset   string
		wantErr string
	}{
		{"missing server url", "SERVER_URL", "SERVER_URL"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			vals := requiredVals()
			delete(vals, tc.unset)
			_, err := loadConfig(fakeGetenv(vals))
			if err == nil {
				t.Fatal("loadConfig: want error, got nil")
			}
			if !strings.Contains(err.Error(), tc.wantErr) {
				t.Errorf("error = %q, want to mention %q", err.Error(), tc.wantErr)
			}
		})
	}
}

func TestLoadConfigMissingAllRequiredVars(t *testing.T) {
	_, err := loadConfig(fakeGetenv(nil))
	if err == nil {
		t.Fatal("loadConfig: want error, got nil")
	}
	for _, want := range []string{"SERVER_URL", "CI_PAT_FILE"} {
		if !strings.Contains(err.Error(), want) {
			t.Errorf("error = %q, missing %q", err.Error(), want)
		}
	}
}

// TestLoadConfigCredentialChainIsOptional pins which credentials a plane may
// leave out. A plane fronted by a real identity provider has no password to
// post and hands over an operator PAT instead; a plane with a password issuer
// signs in and mints its own. Only a plane offering neither is misconfigured —
// and setting exactly one half of the sign-in pair is a typo, not a choice.
func TestLoadConfigCredentialChainIsOptional(t *testing.T) {
	cases := []struct {
		name    string
		unset   []string
		wantErr string
	}{
		{"both unset skips the proof", []string{"CI_USER", "CI_PASSWORD_FILE"}, ""},
		{"user without password file", []string{"CI_PASSWORD_FILE"}, "must be set together"},
		{"password file without user", []string{"CI_USER"}, "must be set together"},
		// A sign-in mints its own token, so a plane with a password issuer needs
		// no operator PAT pasted anywhere.
		{"sign-in without an operator PAT", []string{"CI_PAT_FILE"}, ""},
		// With neither, nothing can authenticate the VM lifecycle.
		{
			"neither a sign-in nor a PAT",
			[]string{"CI_USER", "CI_PASSWORD_FILE", "CI_PAT_FILE"},
			"CI_PAT_FILE",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			vals := requiredVals()
			for _, k := range tc.unset {
				delete(vals, k)
			}
			cfg, err := loadConfig(fakeGetenv(vals))
			if tc.wantErr == "" {
				if err != nil {
					t.Fatalf("loadConfig: %v", err)
				}
				got := map[string]string{
					"CI_USER":          cfg.CIUser,
					"CI_PASSWORD_FILE": cfg.CIPasswordFile,
					"CI_PAT_FILE":      cfg.CIPATFile,
				}
				for _, k := range tc.unset {
					if got[k] != "" {
						t.Errorf("%s = %q, want empty", k, got[k])
					}
				}
				return
			}
			if err == nil {
				t.Fatal("loadConfig: want error, got nil")
			}
			if !strings.Contains(err.Error(), tc.wantErr) {
				t.Errorf("error = %q, want to mention %q", err.Error(), tc.wantErr)
			}
		})
	}
}

// TestLoadConfigMCPURLs pins the one documented rule of the list: unset means
// the console origin alone, so the branch gate is unchanged, and the order of
// an explicit list is preserved — the first entry is the one that gets the full
// leg.
func TestLoadConfigMCPURLs(t *testing.T) {
	cases := []struct {
		name string
		env  string
		want []string
	}{
		{"unset defaults to the server url", "", []string{"https://server.example:8443"}},
		{"one origin", "https://api.stg.eitri.sh", []string{"https://api.stg.eitri.sh"}},
		{
			"two origins keep their order",
			"https://stg.eitri.sh https://api.stg.eitri.sh",
			[]string{"https://stg.eitri.sh", "https://api.stg.eitri.sh"},
		},
		{
			"surrounding and repeated whitespace is not an origin",
			"  https://stg.eitri.sh \t https://api.stg.eitri.sh  ",
			[]string{"https://stg.eitri.sh", "https://api.stg.eitri.sh"},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			vals := requiredVals()
			if tc.env != "" {
				vals["SMOKE_MCP_URL"] = tc.env
			}
			cfg, err := loadConfig(fakeGetenv(vals))
			if err != nil {
				t.Fatalf("loadConfig: %v", err)
			}
			if len(cfg.MCPURLs) != len(tc.want) {
				t.Fatalf("MCPURLs = %v, want %v", cfg.MCPURLs, tc.want)
			}
			for i := range tc.want {
				if cfg.MCPURLs[i] != tc.want[i] {
					t.Errorf("MCPURLs[%d] = %q, want %q", i, cfg.MCPURLs[i], tc.want[i])
				}
			}
		})
	}
}

func TestLoadConfigSmokeGateDefaults(t *testing.T) {
	cfg, err := loadConfig(fakeGetenv(requiredVals()))
	if err != nil {
		t.Fatalf("loadConfig: %v", err)
	}
	if cfg.SmokeGate != "" {
		t.Errorf("SmokeGate = %q, want empty", cfg.SmokeGate)
	}
	if cfg.SmokeVMUser != guest.LoginUser {
		t.Errorf("SmokeVMUser = %q, want %q", cfg.SmokeVMUser, guest.LoginUser)
	}
	if cfg.SmokeUserCAFile != "" {
		t.Errorf("SmokeUserCAFile = %q, want empty", cfg.SmokeUserCAFile)
	}
}

func TestLoadConfigSmokeGatePassThrough(t *testing.T) {
	vals := requiredVals()
	vals["SMOKE_GATE"] = "gate.example:2222"
	vals["SMOKE_VM_USER"] = "debian"
	vals["SMOKE_USER_CA_FILE"] = "/etc/eitri-smoke/user_ca"
	cfg, err := loadConfig(fakeGetenv(vals))
	if err != nil {
		t.Fatalf("loadConfig: %v", err)
	}
	if cfg.SmokeGate != "gate.example:2222" {
		t.Errorf("SmokeGate = %q, want gate.example:2222", cfg.SmokeGate)
	}
	if cfg.SmokeVMUser != "debian" {
		t.Errorf("SmokeVMUser = %q, want debian", cfg.SmokeVMUser)
	}
	if cfg.SmokeUserCAFile != "/etc/eitri-smoke/user_ca" {
		t.Errorf("SmokeUserCAFile = %q, want /etc/eitri-smoke/user_ca", cfg.SmokeUserCAFile)
	}
}

// TestLoadConfigNeedsNoHostWithoutCoverage pins what the gate itself needs: a
// server URL and a credential. Nothing it proves goes near a host, so a run
// that collects no agent coverage names no ssh target at all.
func TestLoadConfigNeedsNoHostWithoutCoverage(t *testing.T) {
	vals := requiredVals()
	delete(vals, "AGENT_HOSTS")
	cfg, err := loadConfig(fakeGetenv(vals))
	if err != nil {
		t.Fatalf("loadConfig: %v", err)
	}
	if cfg.AgentUserHost != "" {
		t.Errorf("AgentUserHost = %q, want empty", cfg.AgentUserHost)
	}
}

// TestLoadConfigNeedsAHostToCollectAgentCoverage: pulling the agent's raw
// profile is the one thing here that reaches onto a host, so that is the one
// setting that makes an ssh target required.
func TestLoadConfigNeedsAHostToCollectAgentCoverage(t *testing.T) {
	vals := requiredVals()
	delete(vals, "AGENT_HOSTS")
	vals["AGENT_GOCOVERDIR"] = "/var/lib/eitri-agent/coverage"
	_, err := loadConfig(fakeGetenv(vals))
	if err == nil {
		t.Fatal("loadConfig: want error when coverage has no host to pull from, got nil")
	}
	if !strings.Contains(err.Error(), "AGENT_HOSTS") {
		t.Errorf("error = %q, want it to name AGENT_HOSTS", err.Error())
	}
}

func TestLoadConfigOptionalCoverageVarsPassThrough(t *testing.T) {
	vals := requiredVals()
	vals["SERVER_GOCOVERDIR"] = "/tmp/server-cover"
	vals["AGENT_GOCOVERDIR"] = "/tmp/agent-cover"
	vals["COVER_OUT"] = "/tmp/out"
	cfg, err := loadConfig(fakeGetenv(vals))
	if err != nil {
		t.Fatalf("loadConfig: %v", err)
	}
	if cfg.ServerGocoverdir != "/tmp/server-cover" || cfg.AgentGocoverdir != "/tmp/agent-cover" || cfg.CoverOut != "/tmp/out" {
		t.Errorf("optional coverage vars not passed through: %+v", cfg)
	}
}

// TestLoadConfigCarriesTheExpectedAgentVersion: a ship names the release it is
// proving, and everything else leaves the expectation empty — a boot gate
// proves binaries built from a working tree, whose stamped version is "dev".
func TestLoadConfigCarriesTheExpectedAgentVersion(t *testing.T) {
	cfg, err := loadConfig(fakeGetenv(requiredVals()))
	if err != nil {
		t.Fatalf("loadConfig: %v", err)
	}
	if cfg.ExpectAgentVersion != "" {
		t.Errorf("ExpectAgentVersion = %q, want empty when nothing names a release", cfg.ExpectAgentVersion)
	}

	vals := requiredVals()
	vals["SMOKE_EXPECT_AGENT_VERSION"] = "v0.0.7"
	cfg, err = loadConfig(fakeGetenv(vals))
	if err != nil {
		t.Fatalf("loadConfig: %v", err)
	}
	if cfg.ExpectAgentVersion != "v0.0.7" {
		t.Errorf("ExpectAgentVersion = %q, want v0.0.7", cfg.ExpectAgentVersion)
	}
}