package session import ( "errors" "fmt" "os" "path/filepath" "strings" "testing" "time" "banger/internal/model" "golang.org/x/crypto/ssh" ) func TestRelativeStateDir(t *testing.T) { got := RelativeStateDir("abc") if strings.HasPrefix(got, "/root/") { t.Fatalf("RelativeStateDir(%q) = %q, should strip /root/ prefix", "abc", got) } if !strings.Contains(got, "abc") { t.Fatalf("missing session id in %q", got) } absolute := StateDir("abc") if got != strings.TrimPrefix(absolute, "/root/") { t.Fatalf("relative = %q, want %q", got, strings.TrimPrefix(absolute, "/root/")) } } func TestDefaultCWD(t *testing.T) { if DefaultCWD("") != "/root" { t.Error("empty should return /root") } if DefaultCWD(" ") != "/root" { t.Error("whitespace should return /root") } if DefaultCWD("/work") != "/work" { t.Error("explicit should pass through") } } func TestShellQuote(t *testing.T) { if got := ShellQuote(""); got != "''" { t.Errorf("empty: got %q, want ''", got) } if got := ShellQuote("x"); got != "'x'" { t.Errorf("plain: got %q", got) } if got := ShellQuote("it's"); got != `'it'"'"'s'` { t.Errorf("apostrophe: got %q", got) } } func TestExitCode(t *testing.T) { if code, ok := ExitCode(nil); !ok || code != 0 { t.Errorf("nil err: got (%d, %v), want (0, true)", code, ok) } // Build an ssh.ExitError using its real type — can't hand-construct, // so wrap via errors.As check with a stub. raw := &ssh.ExitError{} if _, ok := ExitCode(raw); !ok { t.Error("ssh.ExitError: ok should be true") } if _, ok := ExitCode(errors.New("bare error")); ok { t.Error("bare error: ok should be false") } } func TestCloneStringMap(t *testing.T) { if CloneStringMap(nil) != nil { t.Error("nil in → nil out") } if CloneStringMap(map[string]string{}) != nil { t.Error("empty in → nil out") } src := map[string]string{"a": "1", "b": "2"} cloned := CloneStringMap(src) if len(cloned) != 2 { t.Fatalf("len = %d, want 2", len(cloned)) } cloned["a"] = "changed" if src["a"] != "1" { t.Error("mutating clone leaked back to source") } } func TestTailFileContent(t *testing.T) { // Missing file → empty, no error. got, err := TailFileContent(filepath.Join(t.TempDir(), "missing"), 10) if err != nil || got != "" { t.Errorf("missing: got (%q, %v), want ('', nil)", got, err) } path := filepath.Join(t.TempDir(), "log") lines := "one\ntwo\nthree\nfour\nfive" if err := os.WriteFile(path, []byte(lines), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } full, err := TailFileContent(path, 0) if err != nil || full != lines { t.Errorf("0 lines: got (%q, %v), want (%q, nil)", full, err, lines) } // Request more lines than exist → full content. all, err := TailFileContent(path, 999) if err != nil || all != lines { t.Errorf("999 lines: got %q", all) } last2, err := TailFileContent(path, 2) if err != nil { t.Fatalf("2 lines: %v", err) } if !strings.Contains(last2, "five") { t.Errorf("2 lines missing last line: %q", last2) } } func TestProcessAlive(t *testing.T) { if ProcessAlive(0) { t.Error("pid 0 should not be alive") } if ProcessAlive(-1) { t.Error("negative pid should not be alive") } // Swap the syscall seam. original := syscallKill t.Cleanup(func() { syscallKill = original }) syscallKill = func(pid int, signal os.Signal) error { return nil } if !ProcessAlive(42) { t.Error("syscallKill=nil should report alive") } syscallKill = func(pid int, signal os.Signal) error { return fmt.Errorf("no such process") } if ProcessAlive(42) { t.Error("syscallKill error should report dead") } } func TestFormatStepError(t *testing.T) { base := errors.New("boom") err := FormatStepError("prepare", base, "") if !errors.Is(err, base) { t.Error("FormatStepError should wrap the base error") } if !strings.Contains(err.Error(), "prepare") { t.Errorf("missing action: %v", err) } errWithLog := FormatStepError("prepare", base, " log line\n") if !strings.Contains(errWithLog.Error(), "log line") { t.Errorf("missing log: %v", errWithLog) } } func TestParseStateHappyPath(t *testing.T) { raw := `status=running pid=123 exit= alive=true error= ` snap, err := ParseState(raw) if err != nil { t.Fatalf("ParseState: %v", err) } if snap.Status != "running" { t.Errorf("Status = %q", snap.Status) } if snap.GuestPID != 123 { t.Errorf("GuestPID = %d", snap.GuestPID) } if snap.ExitCode != nil { t.Errorf("ExitCode should be nil when empty, got %v", snap.ExitCode) } if !snap.Alive { t.Error("Alive should be true") } } func TestParseStateWithExit(t *testing.T) { raw := `status=exited pid=123 exit=7 alive=false error=something bad ` snap, err := ParseState(raw) if err != nil { t.Fatalf("ParseState: %v", err) } if snap.ExitCode == nil || *snap.ExitCode != 7 { t.Errorf("ExitCode = %v, want 7", snap.ExitCode) } if snap.LastError != "something bad" { t.Errorf("LastError = %q", snap.LastError) } if snap.Alive { t.Error("Alive should be false") } } func TestParseStateIgnoresMalformedLines(t *testing.T) { raw := "no-equals-here\nstatus=ok\n" snap, err := ParseState(raw) if err != nil { t.Fatalf("ParseState: %v", err) } if snap.Status != "ok" { t.Errorf("Status = %q, want ok", snap.Status) } } func TestInspectStateFromDir(t *testing.T) { dir := t.TempDir() writeFile := func(name, content string) { if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { t.Fatalf("WriteFile(%s): %v", name, err) } } writeFile("status", "running\n") writeFile("pid", "42\n") writeFile("exit_code", "0\n") writeFile("error", "\n") original := syscallKill t.Cleanup(func() { syscallKill = original }) syscallKill = func(pid int, signal os.Signal) error { return nil } snap, err := InspectStateFromDir(dir) if err != nil { t.Fatalf("InspectStateFromDir: %v", err) } if snap.Status != "running" { t.Errorf("Status = %q", snap.Status) } if snap.GuestPID != 42 { t.Errorf("GuestPID = %d", snap.GuestPID) } if snap.ExitCode == nil || *snap.ExitCode != 0 { t.Errorf("ExitCode = %v, want 0", snap.ExitCode) } if !snap.Alive { t.Error("Alive should reflect syscallKill result (true)") } } func TestInspectStateFromDirMissingFiles(t *testing.T) { snap, err := InspectStateFromDir(t.TempDir()) if err != nil { t.Fatalf("InspectStateFromDir (empty): %v", err) } if snap.Status != "" || snap.GuestPID != 0 || snap.ExitCode != nil { t.Errorf("empty dir: snap = %+v", snap) } } func TestApplyStateSnapshotNilReceiver(t *testing.T) { ApplyStateSnapshot(nil, StateSnapshot{}, true) // should not panic } func TestApplyStateSnapshotExitedSuccess(t *testing.T) { exit := 0 sess := &model.GuestSession{Status: model.GuestSessionStatusRunning, Attachable: true, Reattachable: true} ApplyStateSnapshot(sess, StateSnapshot{ExitCode: &exit}, true) if sess.Status != model.GuestSessionStatusExited { t.Errorf("Status = %q, want exited", sess.Status) } if sess.Attachable || sess.Reattachable { t.Error("attach flags should be cleared on exit") } if sess.EndedAt.IsZero() { t.Error("EndedAt should be set") } } func TestApplyStateSnapshotExitedFailure(t *testing.T) { exit := 2 sess := &model.GuestSession{Status: model.GuestSessionStatusRunning} ApplyStateSnapshot(sess, StateSnapshot{ExitCode: &exit}, true) if sess.Status != model.GuestSessionStatusFailed { t.Errorf("Status = %q, want failed", sess.Status) } } func TestApplyStateSnapshotVMGone(t *testing.T) { sess := &model.GuestSession{Status: model.GuestSessionStatusRunning} ApplyStateSnapshot(sess, StateSnapshot{Alive: false}, false) if sess.Status != model.GuestSessionStatusFailed { t.Errorf("Status = %q, want failed", sess.Status) } if sess.LastError == "" { t.Error("LastError should be populated when VM is gone") } } func TestApplyStateSnapshotRunningStatusSetsAttachableForPipe(t *testing.T) { // When the guest-side status file reports "running" (Alive=false from // kill -0 may still fail transiently), ApplyStateSnapshot transitions // the session to running and sets attach flags for pipe-mode. sess := &model.GuestSession{ Status: model.GuestSessionStatusStarting, StdinMode: model.GuestSessionStdinPipe, } ApplyStateSnapshot(sess, StateSnapshot{Status: string(model.GuestSessionStatusRunning), GuestPID: 11}, true) if sess.Status != model.GuestSessionStatusRunning { t.Errorf("Status = %q, want running", sess.Status) } if !sess.Attachable || !sess.Reattachable { t.Error("pipe-mode running session should be attachable + reattachable") } if sess.AttachBackend != AttachBackendSSHBridge { t.Errorf("AttachBackend = %q, want %q", sess.AttachBackend, AttachBackendSSHBridge) } } func TestApplyStateSnapshotAliveEarlyReturn(t *testing.T) { // Alive-true returns immediately after setting status; no attach // flags set on this path (by design — attach metadata only attaches // to status-driven transitions). sess := &model.GuestSession{ Status: model.GuestSessionStatusStarting, StdinMode: model.GuestSessionStdinPipe, } ApplyStateSnapshot(sess, StateSnapshot{Alive: true, GuestPID: 11}, true) if sess.Status != model.GuestSessionStatusRunning { t.Errorf("Status = %q, want running", sess.Status) } if sess.StartedAt.IsZero() { t.Error("StartedAt should have been set") } } func TestStateChanged(t *testing.T) { base := model.GuestSession{Status: model.GuestSessionStatusRunning, GuestPID: 10} // Identical → no change. if StateChanged(base, base) { t.Error("identical states should not be considered changed") } // Status change. changed := base changed.Status = model.GuestSessionStatusExited if !StateChanged(base, changed) { t.Error("status change should be detected") } // ExitCode change from nil → value. exit := 3 changed = base changed.ExitCode = &exit if !StateChanged(base, changed) { t.Error("exit-code appearing should be detected") } // Both have the same exit code → no change. a := base a.ExitCode = &exit b := base b.ExitCode = &exit if StateChanged(a, b) { t.Error("matching exit codes should not trigger change") } // Different exit codes. other := 5 b.ExitCode = &other if !StateChanged(a, b) { t.Error("differing exit codes should be detected") } // Timestamp change. changed = base changed.StartedAt = time.Now() if !StateChanged(base, changed) { t.Error("StartedAt change should be detected") } } func TestFailLaunch(t *testing.T) { in := model.GuestSession{Status: model.GuestSessionStatusStarting, Attachable: true} out := FailLaunch(in, "provision", " ssh did not come up ", " raw output\n") if out.Status != model.GuestSessionStatusFailed { t.Errorf("Status = %q, want failed", out.Status) } if out.LastError != "ssh did not come up" { t.Errorf("LastError = %q (not trimmed?)", out.LastError) } if out.LaunchStage != "provision" || out.LaunchMessage != "ssh did not come up" { t.Errorf("launch fields not set: %+v", out) } if out.LaunchRawLog != "raw output" { t.Errorf("rawLog = %q (not trimmed?)", out.LaunchRawLog) } if out.Attachable { t.Error("Attachable should be cleared") } } func TestNormalizeRequiredCommands(t *testing.T) { got := NormalizeRequiredCommands("pi", []string{"pi", "git", "", "git", " ", "make"}) want := []string{"pi", "git", "make"} if len(got) != len(want) { t.Fatalf("len = %d, want %d (%v)", len(got), len(want), got) } for i, v := range want { if got[i] != v { t.Errorf("position %d: got %q, want %q", i, got[i], v) } } } func TestInspectScriptContainsAllStateFiles(t *testing.T) { script := InspectScript("sess-abc") for _, key := range []string{"status", "pid", "exit_code", "error", "alive"} { if !strings.Contains(script, key) { t.Errorf("script missing %q:\n%s", key, script) } } if !strings.Contains(script, "sess-abc") { t.Error("script missing session id") } } func TestSignalScriptIncludesSignalAndDirPaths(t *testing.T) { script := SignalScript("sess-x", "TERM") if !strings.Contains(script, "TERM") { t.Error("missing signal") } if !strings.Contains(script, "sess-x") { t.Error("missing session id") } if !strings.Contains(script, "monitor_pid") || !strings.Contains(script, "stdin_keepalive") { t.Errorf("expected both monitor + stdin_keepalive kills, got:\n%s", script) } }