//go:build darwin package daemon import ( "encoding/xml" "fmt" "os" "path/filepath" "strings" "testing" ) func TestBuildPlist_KeepAliveDoesNotRestartOnCleanExit(t *testing.T) { cfg := Config{ BinaryPath: "/opt/cc-connect/cc-connect", WorkDir: "/tmp/wd", LogFile: "/tmp/log", LogMaxSize: 10485760, EnvPATH: "/usr/bin", } xml := buildPlist(cfg) if !strings.Contains(xml, "SuccessfulExit") { t.Fatal("plist should use KeepAlive dict with SuccessfulExit so exit 0 does not respawn") } // Boolean KeepAlive causes launchd to restart after every exit, including SIGTERM shutdown. if strings.Contains(xml, "KeepAlive\n\t") { t.Fatal("plist must not use boolean KeepAlive true") } // launchd.plist(5): SuccessfulExit=true means restart ONLY after a successful // (exit 0) exit; false means restart ONLY after an unsuccessful exit. cc-connect // returns 0 on graceful SIGTERM shutdown but a non-zero status on crash, so the // daemon's "restart on failure but not on graceful stop" intent maps to // SuccessfulExit=false. The previous wiring used , which was the inverse: // it respawned after every clean SIGTERM shutdown and did NOT recover from // crashes. Pin the correct value here so a future edit can't silently re-invert // it. if !strings.Contains(xml, "SuccessfulExit\n\t\t") { t.Fatalf("plist must set SuccessfulExit=false so crashes restart and clean SIGTERM does not respawn; got:\n%s", xml) } if !strings.Contains(xml, "LimitLoadToSessionType") || !strings.Contains(xml, "Aqua") || !strings.Contains(xml, "Background") { t.Fatal("plist should allow both Aqua and Background sessions") } } func TestPreferredLaunchdDomainFallsBackToUserWhenGUIDomainUnavailable(t *testing.T) { orig := runLaunchctl t.Cleanup(func() { runLaunchctl = orig }) guiDomain := launchdGUIDomain() userDomain := launchdUserDomain() runLaunchctl = func(args ...string) (string, error) { if len(args) >= 2 && args[0] == "print" && args[1] == guiDomain { return "Bootstrap failed: 125: Domain does not support specified action", fmt.Errorf("exit status 125") } if len(args) >= 2 && args[0] == "print" && args[1] == userDomain { return "subsystem", nil } return "", nil } if got := preferredLaunchdDomain(); got != userDomain { t.Fatalf("preferredLaunchdDomain() = %q, want %q", got, userDomain) } } func TestLaunchdStatusUsesUserDomainWhenGUIDomainUnavailable(t *testing.T) { orig := runLaunchctl t.Cleanup(func() { runLaunchctl = orig }) guiDomain := launchdGUIDomain() userDomain := launchdUserDomain() guiTarget := launchdTarget(guiDomain) userTarget := launchdTarget(userDomain) runLaunchctl = func(args ...string) (string, error) { if len(args) < 2 || args[0] != "print" { return "", nil } switch args[1] { case guiDomain, guiTarget: return "Bootstrap failed: 125: Domain does not support specified action", fmt.Errorf("exit status 125") case userDomain: return "subsystem", nil case userTarget: return "pid = 4321\nstate = running", nil default: return "", fmt.Errorf("unexpected target %q", args[1]) } } mgr := &launchdManager{} st, err := mgr.Status() if err != nil { t.Fatalf("Status() error = %v", err) } if !st.Running { t.Fatal("Status().Running = false, want true") } if st.PID != 4321 { t.Fatalf("Status().PID = %d, want 4321", st.PID) } } func TestRestartPrefersGUIDomainWhenAvailable(t *testing.T) { orig := runLaunchctl t.Cleanup(func() { runLaunchctl = orig }) dir := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", dir) if origHome != "" { t.Cleanup(func() { _ = os.Setenv("HOME", origHome) }) } plistPath := launchdPlistPath() if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(plistPath, []byte("plist"), 0644); err != nil { t.Fatalf("WriteFile() error = %v", err) } guiDomain := launchdGUIDomain() userDomain := launchdUserDomain() guiTarget := launchdTarget(guiDomain) userTarget := launchdTarget(userDomain) var calls []string runLaunchctl = func(args ...string) (string, error) { calls = append(calls, strings.Join(args, " ")) if len(args) < 2 { return "", nil } switch args[0] { case "print": switch args[1] { case guiDomain: return "subsystem", nil case guiTarget: return "Bootstrap failed: 113: Could not find service", fmt.Errorf("exit status 113") case userTarget: return "pid = 4321\nstate = running", nil default: return "", fmt.Errorf("unexpected print target %q", args[1]) } case "bootout": return "", nil case "bootstrap": if args[1] != guiDomain { t.Fatalf("bootstrap domain = %q, want %q", args[1], guiDomain) } return "", nil case "kickstart": if args[len(args)-1] != guiTarget { t.Fatalf("kickstart target = %q, want %q", args[len(args)-1], guiTarget) } return "", nil default: return "", nil } } mgr := &launchdManager{} if err := mgr.Restart(); err != nil { t.Fatalf("Restart() error = %v", err) } if !containsCall(calls, "bootstrap "+guiDomain+" "+plistPath) { t.Fatalf("expected bootstrap to gui domain, calls = %#v", calls) } if !containsCall(calls, "kickstart -kp "+guiTarget) { t.Fatalf("expected kickstart to gui target, calls = %#v", calls) } } func TestRestartKeepsUserDomainWhenGUIDomainUnavailable(t *testing.T) { orig := runLaunchctl t.Cleanup(func() { runLaunchctl = orig }) dir := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", dir) if origHome != "" { t.Cleanup(func() { _ = os.Setenv("HOME", origHome) }) } plistPath := launchdPlistPath() if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(plistPath, []byte("plist"), 0644); err != nil { t.Fatalf("WriteFile() error = %v", err) } guiDomain := launchdGUIDomain() userDomain := launchdUserDomain() userTarget := launchdTarget(userDomain) var calls []string runLaunchctl = func(args ...string) (string, error) { calls = append(calls, strings.Join(args, " ")) if len(args) < 2 { return "", nil } switch args[0] { case "print": switch args[1] { case guiDomain: return "Bootstrap failed: 125: Domain does not support specified action", fmt.Errorf("exit status 125") case userDomain: return "subsystem", nil case userTarget: return "pid = 4321\nstate = running", nil default: return "", fmt.Errorf("unexpected print target %q", args[1]) } case "bootout": return "", nil case "bootstrap": if args[1] != userDomain { t.Fatalf("bootstrap domain = %q, want %q", args[1], userDomain) } return "", nil case "kickstart": if args[len(args)-1] != userTarget { t.Fatalf("kickstart target = %q, want %q", args[len(args)-1], userTarget) } return "", nil default: return "", nil } } mgr := &launchdManager{} if err := mgr.Restart(); err != nil { t.Fatalf("Restart() error = %v", err) } if !containsCall(calls, "bootstrap "+userDomain+" "+plistPath) { t.Fatalf("expected bootstrap to user domain, calls = %#v", calls) } if !containsCall(calls, "kickstart -kp "+userTarget) { t.Fatalf("expected kickstart to user target, calls = %#v", calls) } } // collectXMLText walks the XML stream and returns every chardata text node. // Used by the plist-escape test to verify path values round-trip through // xml.Decoder regardless of how deeply they are nested. func collectXMLText(t *testing.T, data []byte) []string { t.Helper() dec := xml.NewDecoder(strings.NewReader(string(data))) var out []string for { tok, err := dec.Token() if tok == nil { break } if err != nil { t.Fatalf("xml decode token: %v", err) } if cd, ok := tok.(xml.CharData); ok { s := strings.TrimSpace(string(cd)) if s != "" { out = append(out, s) } } } return out } func containsCall(calls []string, want string) bool { for _, call := range calls { if call == want { return true } } return false } // TestBuildPlist_EscapesXMLSpecialCharsInPaths pins the bug where unescaped // '&', '<', '>', '"', and '\” in cfg paths produced malformed XML that // `launchctl bootstrap` rejected. func TestBuildPlist_EscapesXMLSpecialCharsInPaths(t *testing.T) { cfg := Config{ BinaryPath: "/opt/cc-connect/bin & /cc-connect", WorkDir: "/Users/jane/Projects/dev & test/cc-connect", LogFile: "/Users/jane/Library/Logs/cc \"connect\".log", LogMaxSize: 10485760, EnvPATH: "/usr/bin:/path/with'apostrophe/bin", } out := buildPlist(cfg) // 1) Result must parse as well-formed XML — without escaping, bare '&' // or unbalanced '<' inside elements break the parser. if err := xml.Unmarshal([]byte(out), new(struct{ XMLName xml.Name })); err != nil { t.Fatalf("buildPlist output is not valid XML: %v\n%s", err, out) } // 2) Round-trip the values through the XML parser and make sure the // original characters survive the encode/decode cycle. Walk every // text node rather than relying on a positional path, since the // plist nests // at multiple depths. values := collectXMLText(t, []byte(out)) mustContain := []string{cfg.BinaryPath, cfg.WorkDir, cfg.LogFile, cfg.EnvPATH} for _, want := range mustContain { found := false for _, got := range values { if got == want { found = true break } } if !found { t.Errorf("decoded plist does not contain expected value %q\nvalues = %#v", want, values) } } // 3) The raw XML output must not contain a bare '&' followed by anything // other than a recognized entity reference — that's what would crash // launchctl. for i := 0; i < len(out); i++ { if out[i] != '&' { continue } rest := out[i:] if !(strings.HasPrefix(rest, "&") || strings.HasPrefix(rest, "<") || strings.HasPrefix(rest, ">") || strings.HasPrefix(rest, """) || strings.HasPrefix(rest, "'") || strings.HasPrefix(rest, "&#")) { t.Fatalf("bare '&' at offset %d (not a valid entity ref): %q", i, rest[:min(len(rest), 30)]) } } }