Reuses existing fixtures (CommandRunner fakes, SQLite tempfile store, pure-Go seams). No new infra needed. hostnat 50% -> 98% (iptables orchestration via fake runner) store 78% -> 91% (guest_sessions CRUD roundtrip) daemon/session 57% -> 95% (script gen, state parse, snapshot apply) daemon/opstate 67% -> 100% (Registry Insert/Get/Prune) daemon (firstNonEmpty) slight bump Total 54.0% -> 56.5%.
440 lines
12 KiB
Go
440 lines
12 KiB
Go
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)
|
|
}
|
|
}
|