|
| 1 | +// SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | +//go:build windows |
| 3 | + |
| 4 | +package visibility |
| 5 | + |
| 6 | +import ( |
| 7 | + "os" |
| 8 | + "os/exec" |
| 9 | + "path/filepath" |
| 10 | + "strings" |
| 11 | + "syscall" |
| 12 | + "testing" |
| 13 | + "time" |
| 14 | +) |
| 15 | + |
| 16 | +// TestCollectParents_IncludesSelf verifies the toolhelp snapshot syscall |
| 17 | +// plumbing. Our own PID must show up in the snapshot with a non-zero |
| 18 | +// parent. |
| 19 | +func TestCollectParents_IncludesSelf(t *testing.T) { |
| 20 | + parents, err := collectParents() |
| 21 | + if err != nil { |
| 22 | + t.Fatalf("collectParents: %v", err) |
| 23 | + } |
| 24 | + self := uint32(os.Getpid()) |
| 25 | + parent, ok := parents[self] |
| 26 | + if !ok { |
| 27 | + t.Fatalf("snapshot does not contain our own PID %d", self) |
| 28 | + } |
| 29 | + if parent == 0 { |
| 30 | + t.Errorf("our parent PID is 0; expected a real PPID") |
| 31 | + } |
| 32 | + if len(parents) < 3 { |
| 33 | + t.Errorf("snapshot has %d entries; expected >= 3 on a real Windows session", len(parents)) |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +// TestEnumWindows_PopulatesCollector verifies the EnumWindows callback |
| 38 | +// plumbing. On any normal desktop session it returns at least one |
| 39 | +// top-level window; we skip rather than fail on session-0 / headless |
| 40 | +// environments where 0 is plausible. |
| 41 | +func TestEnumWindows_PopulatesCollector(t *testing.T) { |
| 42 | + enumCollector = enumCollector[:0] |
| 43 | + enumWindowsProc.Call(enumProcCallback, 0) |
| 44 | + if len(enumCollector) == 0 { |
| 45 | + t.Skip("EnumWindows returned 0 windows; likely a headless or session-0 environment") |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +// TestPerformCheck_PassesInDevEnvironment confirms the full check returns |
| 50 | +// ok=true when run from an interactive session with a visible ancestor |
| 51 | +// window. Auto-skips on CI. |
| 52 | +func TestPerformCheck_PassesInDevEnvironment(t *testing.T) { |
| 53 | + if os.Getenv("CI") != "" { |
| 54 | + t.Skip("skipping live visibility check on CI") |
| 55 | + } |
| 56 | + res := performCheck() |
| 57 | + if !res.ok { |
| 58 | + t.Errorf("performCheck failed in dev environment: %s", res.reason) |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +// TestProbe_SurvivesWithVisibleAncestor builds the probe binary and runs |
| 63 | +// it inheriting our environment. Probe should heartbeat and exit cleanly. |
| 64 | +// Skips if the current environment has no visible ancestor at all (the |
| 65 | +// kill switch would correctly fire there). |
| 66 | +func TestProbe_SurvivesWithVisibleAncestor(t *testing.T) { |
| 67 | + if !performCheck().ok { |
| 68 | + t.Skip("no visible ancestor in this environment; can't test survival here") |
| 69 | + } |
| 70 | + probe := buildProbe(t) |
| 71 | + |
| 72 | + out, err := exec.Command(probe).CombinedOutput() |
| 73 | + if err != nil { |
| 74 | + t.Fatalf("probe exited with error: %v\noutput:\n%s", err, out) |
| 75 | + } |
| 76 | + if !strings.Contains(string(out), "alive") { |
| 77 | + t.Fatalf("probe didn't print heartbeat; output:\n%s", out) |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +// TestProbe_KilledWhenLaunchedHiddenWithoutVisibleAncestor exercises the |
| 82 | +// kill path end-to-end. We spawn the probe with a new hidden console; the |
| 83 | +// probe's own console check fails, and its ancestor walk inherits our |
| 84 | +// process tree. If our process tree also has no visible window (headless |
| 85 | +// CI) the watcher kills the probe within a few seconds. We skip locally |
| 86 | +// where a visible terminal would shield the probe. |
| 87 | +func TestProbe_KilledWhenLaunchedHiddenWithoutVisibleAncestor(t *testing.T) { |
| 88 | + if performCheck().ok { |
| 89 | + t.Skip("visible ancestor in this environment would shield the probe via the ancestor walk. Run headless to exercise the kill path.") |
| 90 | + } |
| 91 | + probe := buildProbe(t) |
| 92 | + |
| 93 | + cmd := exec.Command(probe) |
| 94 | + cmd.SysProcAttr = &syscall.SysProcAttr{ |
| 95 | + HideWindow: true, |
| 96 | + CreationFlags: 0x00000010, // CREATE_NEW_CONSOLE |
| 97 | + } |
| 98 | + |
| 99 | + start := time.Now() |
| 100 | + err := cmd.Run() |
| 101 | + elapsed := time.Since(start) |
| 102 | + |
| 103 | + if err == nil { |
| 104 | + t.Fatal("expected probe to be killed by the watcher; it exited cleanly") |
| 105 | + } |
| 106 | + if exitErr, ok := err.(*exec.ExitError); ok { |
| 107 | + if code := exitErr.ExitCode(); code != 1 { |
| 108 | + t.Errorf("probe exit code = %d, want 1", code) |
| 109 | + } |
| 110 | + } |
| 111 | + if elapsed > 8*time.Second { |
| 112 | + t.Errorf("probe took %v to be killed; watcher should fire by ~3s", elapsed) |
| 113 | + } |
| 114 | + if elapsed < 2*time.Second { |
| 115 | + t.Errorf("probe exited too fast (%v); kill should not fire before the 2s startup grace", elapsed) |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +func buildProbe(t *testing.T) string { |
| 120 | + t.Helper() |
| 121 | + out := filepath.Join(t.TempDir(), "probe.exe") |
| 122 | + cmd := exec.Command("go", "build", "-o", out, "./testdata/probe") |
| 123 | + if combined, err := cmd.CombinedOutput(); err != nil { |
| 124 | + t.Fatalf("build probe: %v\n%s", err, combined) |
| 125 | + } |
| 126 | + return out |
| 127 | +} |
0 commit comments