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