package daemon import ( "context" "errors" "os" "path/filepath" "strings" "testing" "banger/internal/model" "banger/internal/paths" "banger/internal/system" ) // permissiveRunner satisfies system.CommandRunner by returning a // configurable response for every call. Doctor tests don't care about // the exact ip/iptables commands run — they care that the aggregated // report surfaces each feature check correctly, so a one-size runner // keeps the test prelude short. type permissiveRunner struct { out []byte err error } func (r *permissiveRunner) Run(_ context.Context, _ string, _ ...string) ([]byte, error) { return r.out, r.err } func (r *permissiveRunner) RunSudo(_ context.Context, _ ...string) ([]byte, error) { return r.out, r.err } // buildDoctorDaemon stands up a Daemon the way doctorReport expects: // fake PATH with every tool the preflights look for, fake firecracker // + vsock companion binaries, fake vsock host device file, and a // permissive runner that claims a default-route via eth0 so NAT's // defaultUplink call succeeds. Returns the wired *Daemon. func buildDoctorDaemon(t *testing.T) *Daemon { t.Helper() binDir := t.TempDir() for _, name := range []string{ "sudo", "ip", "dmsetup", "losetup", "blockdev", "truncate", "pgrep", "chown", "chmod", "kill", "e2cp", "e2rm", "debugfs", "iptables", "sysctl", "mkfs.ext4", "mount", "umount", "cp", } { writeFakeExecutable(t, filepath.Join(binDir, name)) } t.Setenv("PATH", binDir) firecrackerBin := filepath.Join(t.TempDir(), "firecracker") if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatalf("write firecracker: %v", err) } vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-agent") if err := os.WriteFile(vsockHelper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatalf("write vsock helper: %v", err) } t.Setenv("BANGER_VSOCK_AGENT_BIN", vsockHelper) sshKey := filepath.Join(t.TempDir(), "id_ed25519") if err := os.WriteFile(sshKey, []byte("unused"), 0o600); err != nil { t.Fatalf("write ssh key: %v", err) } vsockHostDevice := filepath.Join(t.TempDir(), "vhost-vsock") if err := os.WriteFile(vsockHostDevice, []byte{}, 0o644); err != nil { t.Fatalf("write vsock host device: %v", err) } runner := &permissiveRunner{out: []byte("default via 10.0.0.1 dev eth0 proto static\n")} d := &Daemon{ layout: paths.Layout{ ConfigDir: t.TempDir(), StateDir: t.TempDir(), DBPath: filepath.Join(t.TempDir(), "state.db"), }, config: model.DaemonConfig{ FirecrackerBin: firecrackerBin, SSHKeyPath: sshKey, BridgeName: model.DefaultBridgeName, BridgeIP: model.DefaultBridgeIP, StatsPollInterval: model.DefaultStatsPollInterval, }, runner: runner, } wireServices(d) d.vm.vsockHostDevice = vsockHostDevice // HostNetwork defaults its own runner to the one on the struct, but // wireServices only copies the Daemon's runner if d.net is nil // before that call — in this test we constructed d.net implicitly, // so belt-and-braces the permissive runner onto HostNetwork too. d.net.runner = runner return d } // findCheck returns the first CheckResult with the given name, or nil // if no such check was emitted. The test helper rather than a method // on Report so the field scope stays tight. func findCheck(report system.Report, name string) *system.CheckResult { for i := range report.Checks { if report.Checks[i].Name == name { return &report.Checks[i] } } return nil } func TestDoctorReport_StoreErrorSurfacesAsFail(t *testing.T) { d := buildDoctorDaemon(t) report := d.doctorReport(context.Background(), errors.New("simulated open failure"), false) check := findCheck(report, "state store") if check == nil { t.Fatal("state store check missing from report") } if check.Status != system.CheckStatusFail { t.Fatalf("state store status = %q, want fail (store error should surface)", check.Status) } joined := strings.Join(check.Details, " ") if !strings.Contains(joined, "simulated open failure") { t.Fatalf("state store details = %q, want the storeErr message", joined) } } func TestDoctorReport_StoreMissingSurfacesAsPassForFreshInstall(t *testing.T) { d := buildDoctorDaemon(t) // Fresh install: the DB file simply doesn't exist yet. doctor must // not treat that as a failure — nothing's broken, the first daemon // start will create the file. The status message should say so, // so a user running `banger doctor` before ever booting a VM // doesn't see a scary red check. report := d.doctorReport(context.Background(), nil, true) check := findCheck(report, "state store") if check == nil { t.Fatal("state store check missing from report") } if check.Status != system.CheckStatusPass { t.Fatalf("state store status = %q, want pass for a missing DB on fresh install", check.Status) } joined := strings.Join(check.Details, " ") if !strings.Contains(joined, "will be created") { t.Fatalf("state store details = %q, want mention of 'will be created' so users know this is expected", joined) } } func TestDoctorReport_StoreSuccessSurfacesAsPass(t *testing.T) { d := buildDoctorDaemon(t) report := d.doctorReport(context.Background(), nil, false) check := findCheck(report, "state store") if check == nil { t.Fatal("state store check missing from report") } if check.Status != system.CheckStatusPass { t.Fatalf("state store status = %q, want pass", check.Status) } } func TestDoctorReport_MissingFirecrackerFailsHostRuntime(t *testing.T) { d := buildDoctorDaemon(t) d.config.FirecrackerBin = filepath.Join(t.TempDir(), "does-not-exist") report := d.doctorReport(context.Background(), nil, false) check := findCheck(report, "host runtime") if check == nil { t.Fatal("host runtime check missing from report") } if check.Status != system.CheckStatusFail { t.Fatalf("host runtime status = %q, want fail when firecracker binary missing", check.Status) } } func TestDoctorReport_IncludesEveryDefaultCapability(t *testing.T) { d := buildDoctorDaemon(t) report := d.doctorReport(context.Background(), nil, false) // Every registered capability that implements doctorCapability must // contribute a check. Current defaults: work-disk, dns, nat. If a // capability is added later it should either extend this list or // register its own check name — either way, the assertion makes // the contract visible. for _, name := range []string{ "feature /root work disk", "feature vm dns", "feature nat", } { if findCheck(report, name) == nil { t.Errorf("capability check %q missing from report", name) } } } func TestDoctorReport_EmitsVMDefaultsProvenance(t *testing.T) { d := buildDoctorDaemon(t) report := d.doctorReport(context.Background(), nil, false) check := findCheck(report, "vm defaults") if check == nil { t.Fatal("vm defaults check missing from report") } if check.Status != system.CheckStatusPass { t.Fatalf("vm defaults status = %q, want pass (this is an always-pass informational check)", check.Status) } joined := strings.Join(check.Details, "\n") for _, needle := range []string{"vcpu:", "memory:", "disk:"} { if !strings.Contains(joined, needle) { t.Errorf("vm defaults details missing %q; got:\n%s", needle, joined) } } }