banger/internal/cli/cli_test.go
Thales Maciel 430f66d5dd Move helper NAT management into Go
Remove the last shell-owned NAT surface by extracting the iptables logic into a shared Go package and using it from both bangerd and a hidden helper bridge in the CLI.

Route customize.sh and interactive.sh through banger internal nat up/down so the remaining shell helpers reuse the same rule logic, resolve the local banger binary explicitly, and tear NAT back down during cleanup.

Drop nat.sh from the runtime bundle and docs now that NAT is Go-managed everywhere, and keep coverage aligned with the new shared package and helper command.

Validation: go test ./..., bash -n customize.sh interactive.sh verify.sh, make build, and a live ./verify.sh --nat run that installed host rules, reached outbound network access, and cleaned them up successfully.
2026-03-17 15:07:49 -03:00

311 lines
8.9 KiB
Go

package cli
import (
"bytes"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"banger/internal/api"
"banger/internal/model"
)
func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
cmd := NewBangerCommand()
names := []string{}
for _, sub := range cmd.Commands() {
names = append(names, sub.Name())
}
want := []string{"daemon", "image", "internal", "tui", "vm"}
if !reflect.DeepEqual(names, want) {
t.Fatalf("subcommands = %v, want %v", names, want)
}
}
func TestInternalNATFlagsExist(t *testing.T) {
root := NewBangerCommand()
internal, _, err := root.Find([]string{"internal"})
if err != nil {
t.Fatalf("find internal: %v", err)
}
nat, _, err := internal.Find([]string{"nat"})
if err != nil {
t.Fatalf("find nat: %v", err)
}
up, _, err := nat.Find([]string{"up"})
if err != nil {
t.Fatalf("find nat up: %v", err)
}
for _, flagName := range []string{"guest-ip", "tap"} {
if up.Flags().Lookup(flagName) == nil {
t.Fatalf("missing flag %q", flagName)
}
}
}
func TestVMCreateFlagsExist(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
create, _, err := vm.Find([]string{"create"})
if err != nil {
t.Fatalf("find create: %v", err)
}
for _, flagName := range []string{"name", "image", "vcpu", "memory", "system-overlay-size", "disk-size", "nat", "no-start"} {
if create.Flags().Lookup(flagName) == nil {
t.Fatalf("missing flag %q", flagName)
}
}
}
func TestVMKillFlagsExist(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
kill, _, err := vm.Find([]string{"kill"})
if err != nil {
t.Fatalf("find kill: %v", err)
}
if kill.Flags().Lookup("signal") == nil {
t.Fatal("missing signal flag")
}
}
func TestVMSetParamsFromFlags(t *testing.T) {
params, err := vmSetParamsFromFlags("devbox", 4, 2048, "16G", true, false)
if err != nil {
t.Fatalf("vmSetParamsFromFlags: %v", err)
}
if params.IDOrName != "devbox" || params.VCPUCount == nil || *params.VCPUCount != 4 {
t.Fatalf("unexpected params: %+v", params)
}
if params.MemoryMiB == nil || *params.MemoryMiB != 2048 {
t.Fatalf("unexpected memory: %+v", params)
}
if params.WorkDiskSize != "16G" {
t.Fatalf("unexpected disk size: %+v", params)
}
if params.NATEnabled == nil || !*params.NATEnabled {
t.Fatalf("unexpected nat value: %+v", params)
}
}
func TestVMCreateParamsFromFlagsUsesNilForOmittedCPUAndMemory(t *testing.T) {
cmd := NewBangerCommand()
vm, _, err := cmd.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
create, _, err := vm.Find([]string{"create"})
if err != nil {
t.Fatalf("find create: %v", err)
}
params, err := vmCreateParamsFromFlags(create, "devbox", "default", 0, 0, "8G", "16G", false, false)
if err != nil {
t.Fatalf("vmCreateParamsFromFlags: %v", err)
}
if params.VCPUCount != nil || params.MemoryMiB != nil {
t.Fatalf("expected omitted cpu/memory to stay nil: %+v", params)
}
}
func TestVMCreateParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) {
cmd := NewBangerCommand()
vm, _, err := cmd.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
create, _, err := vm.Find([]string{"create"})
if err != nil {
t.Fatalf("find create: %v", err)
}
if err := create.Flags().Set("vcpu", "0"); err != nil {
t.Fatalf("set vcpu flag: %v", err)
}
if _, err := vmCreateParamsFromFlags(create, "devbox", "default", 0, 0, "", "", false, false); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
t.Fatalf("vmCreateParamsFromFlags(vcpu=0) error = %v", err)
}
if err := create.Flags().Set("memory", "-1"); err != nil {
t.Fatalf("set memory flag: %v", err)
}
if _, err := vmCreateParamsFromFlags(create, "devbox", "default", 1, -1, "", "", false, false); err == nil || !strings.Contains(err.Error(), "memory must be a positive integer") {
t.Fatalf("vmCreateParamsFromFlags(memory=-1) error = %v", err)
}
}
func TestVMSetParamsFromFlagsConflict(t *testing.T) {
if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil {
t.Fatal("expected nat conflict error")
}
}
func TestVMSetParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) {
if _, err := vmSetParamsFromFlags("devbox", 0, -1, "", false, false); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
t.Fatalf("vmSetParamsFromFlags(vcpu=0) error = %v", err)
}
if _, err := vmSetParamsFromFlags("devbox", -1, 0, "", false, false); err == nil || !strings.Contains(err.Error(), "memory must be a positive integer") {
t.Fatalf("vmSetParamsFromFlags(memory=0) error = %v", err)
}
}
func TestSSHCommandArgs(t *testing.T) {
args, err := sshCommandArgs(model.DaemonConfig{SSHKeyPath: "/bundle/id_ed25519"}, "172.16.0.2", []string{"--", "uname", "-a"})
if err != nil {
t.Fatalf("sshCommandArgs: %v", err)
}
want := []string{
"-i", "/bundle/id_ed25519",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"root@172.16.0.2",
"--", "uname", "-a",
}
if !reflect.DeepEqual(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestValidateSSHPrereqs(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "id_ed25519")
if err := os.WriteFile(keyPath, []byte("key"), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
if err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: keyPath}); err != nil {
t.Fatalf("validateSSHPrereqs: %v", err)
}
}
func TestValidateSSHPrereqsFailsForMissingKey(t *testing.T) {
err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: "/does/not/exist"})
if err == nil || !strings.Contains(err.Error(), "ssh private key") {
t.Fatalf("validateSSHPrereqs() error = %v, want missing key", err)
}
}
func TestNewBangerdCommandRejectsArgs(t *testing.T) {
cmd := NewBangerdCommand()
cmd.SetArgs([]string{"extra"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected extra args to be rejected")
}
}
func TestDaemonOutdated(t *testing.T) {
dir := t.TempDir()
current := filepath.Join(dir, "bangerd-current")
same := filepath.Join(dir, "bangerd-same")
stale := filepath.Join(dir, "bangerd-stale")
if err := os.WriteFile(current, []byte("current"), 0o755); err != nil {
t.Fatalf("write current: %v", err)
}
if err := os.Link(current, same); err != nil {
t.Fatalf("hard link: %v", err)
}
if err := os.WriteFile(stale, []byte("stale"), 0o755); err != nil {
t.Fatalf("write stale: %v", err)
}
origBangerdPath := bangerdPathFunc
origDaemonExePath := daemonExePath
t.Cleanup(func() {
bangerdPathFunc = origBangerdPath
daemonExePath = origDaemonExePath
})
bangerdPathFunc = func() (string, error) {
return current, nil
}
daemonExePath = func(pid int) string {
if pid == 1 {
return same
}
return stale
}
if daemonOutdated(1) {
t.Fatal("expected matching daemon executable to be current")
}
if !daemonOutdated(2) {
t.Fatal("expected replaced daemon executable to be outdated")
}
}
func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) {
configHome := filepath.Join(t.TempDir(), "config")
stateHome := filepath.Join(t.TempDir(), "state")
runtimeHome := filepath.Join(t.TempDir(), "runtime")
t.Setenv("XDG_CONFIG_HOME", configHome)
t.Setenv("XDG_STATE_HOME", stateHome)
t.Setenv("XDG_RUNTIME_DIR", runtimeHome)
cmd := NewBangerCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stdout)
cmd.SetArgs([]string{"daemon", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "stopped\n") {
t.Fatalf("output = %q, want stopped status", output)
}
if !strings.Contains(output, "log: "+filepath.Join(stateHome, "banger", "bangerd.log")) {
t.Fatalf("output = %q, want daemon log path", output)
}
}
func TestBuildDaemonCommandIsDetachedFromCallerContext(t *testing.T) {
cmd := buildDaemonCommand("/tmp/bangerd")
if cmd.Path != "/tmp/bangerd" {
t.Fatalf("command path = %q", cmd.Path)
}
if cmd.Cancel != nil {
t.Fatal("daemon process should not be tied to a CLI request context")
}
}
func TestAbsolutizeImageBuildPaths(t *testing.T) {
dir := t.TempDir()
prev, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(prev)
})
params := api.ImageBuildParams{
BaseRootfs: "images/base.ext4",
KernelPath: "/kernel",
InitrdPath: "boot/initrd.img",
ModulesDir: "modules",
}
if err := absolutizeImageBuildPaths(&params); err != nil {
t.Fatalf("absolutizeImageBuildPaths: %v", err)
}
want := api.ImageBuildParams{
BaseRootfs: filepath.Join(dir, "images/base.ext4"),
KernelPath: "/kernel",
InitrdPath: filepath.Join(dir, "boot/initrd.img"),
ModulesDir: filepath.Join(dir, "modules"),
}
if !reflect.DeepEqual(params, want) {
t.Fatalf("params = %+v, want %+v", params, want)
}
}