fix: close Ticket 11 hardening review gaps
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
func TestRealPTYProcessRestoresAlternateScreenOnNormalAndDeferredMutationExit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
binary := filepath.Join(root, "bin", "proxyctl")
|
||||
if err := os.MkdirAll(filepath.Dir(binary), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
build := exec.Command("go", "build", "-tags", "proxyctl_process_test", "-o", binary, ".")
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build process fixture: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
t.Run("normal exit", func(t *testing.T) {
|
||||
process := startPTYProcess(t, binary, isolatedPTYEnvironment(t, filepath.Join(root, "normal")))
|
||||
process.waitForOutput(t, "Target availability and state checks completed")
|
||||
process.write(t, "q")
|
||||
process.waitForExit(t)
|
||||
assertAlternateScreenRestored(t, process.output())
|
||||
})
|
||||
|
||||
t.Run("exit deferred after Target mutation starts", func(t *testing.T) {
|
||||
processRoot := filepath.Join(root, "deferred")
|
||||
environment := isolatedPTYEnvironment(t, processRoot)
|
||||
fifo := filepath.Join(processRoot, "mutation-release.fifo")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started := filepath.Join(processRoot, "mutation-started")
|
||||
environment = append(environment,
|
||||
"PROXYCTL_PROCESS_TEST_MUTATION_FIFO="+fifo,
|
||||
"PROXYCTL_PROCESS_TEST_MUTATION_STARTED="+started,
|
||||
)
|
||||
writeConfig(t, filepath.Join(processRoot, "config", "proxyctl", "profiles.json"), `{
|
||||
"profiles": [{"name":"office","mode":"single","singleUrl":"http://proxy.example:8080"}],
|
||||
"activeProfile": "office"
|
||||
}`)
|
||||
|
||||
process := startPTYProcess(t, binary, environment)
|
||||
process.waitForOutput(t, "Disabled Blocking mutation Target")
|
||||
process.write(t, "\x1b[B ")
|
||||
waitForPath(t, started)
|
||||
process.write(t, "\x03")
|
||||
process.waitForOutput(t, "Exit requested — waiting for the current mutation to finish safely")
|
||||
process.assertRunning(t)
|
||||
|
||||
release, err := os.OpenFile(fifo, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open mutation release FIFO: %v", err)
|
||||
}
|
||||
if _, err := release.Write([]byte("release\n")); err != nil {
|
||||
t.Fatalf("release mutation: %v", err)
|
||||
}
|
||||
if err := release.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process.waitForExit(t)
|
||||
assertAlternateScreenRestored(t, process.output())
|
||||
})
|
||||
}
|
||||
|
||||
type ptyProcess struct {
|
||||
command *exec.Cmd
|
||||
terminal *os.File
|
||||
done chan error
|
||||
readDone chan struct{}
|
||||
changed chan struct{}
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
}
|
||||
|
||||
func startPTYProcess(t *testing.T, binary string, environment []string) *ptyProcess {
|
||||
t.Helper()
|
||||
command := exec.Command(binary)
|
||||
command.Env = environment
|
||||
terminal, err := pty.StartWithSize(command, &pty.Winsize{Rows: 36, Cols: 120})
|
||||
if err != nil {
|
||||
t.Fatalf("start process under PTY: %v", err)
|
||||
}
|
||||
process := &ptyProcess{
|
||||
command: command, terminal: terminal, done: make(chan error, 1),
|
||||
readDone: make(chan struct{}), changed: make(chan struct{}, 1),
|
||||
}
|
||||
go func() {
|
||||
_, _ = io.Copy(process, terminal)
|
||||
close(process.readDone)
|
||||
}()
|
||||
go func() { process.done <- command.Wait() }()
|
||||
t.Cleanup(func() {
|
||||
_ = terminal.Close()
|
||||
if command.Process != nil {
|
||||
_ = command.Process.Kill()
|
||||
}
|
||||
})
|
||||
return process
|
||||
}
|
||||
|
||||
func (p *ptyProcess) Write(data []byte) (int, error) {
|
||||
p.mu.Lock()
|
||||
_, _ = p.buffer.Write(data)
|
||||
p.mu.Unlock()
|
||||
select {
|
||||
case p.changed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (p *ptyProcess) write(t *testing.T, value string) {
|
||||
t.Helper()
|
||||
if _, err := p.terminal.WriteString(value); err != nil {
|
||||
t.Fatalf("write PTY input: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ptyProcess) output() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.buffer.String()
|
||||
}
|
||||
|
||||
func (p *ptyProcess) waitForOutput(t *testing.T, expected string) {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
if strings.Contains(p.output(), expected) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-p.changed:
|
||||
case err := <-p.done:
|
||||
t.Fatalf("process exited before output %q: %v\n%s", expected, err, p.output())
|
||||
case <-timer.C:
|
||||
t.Fatalf("timed out waiting for output %q\n%s", expected, p.output())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ptyProcess) assertRunning(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-p.done:
|
||||
t.Fatalf("process exited while Target mutation was blocked: %v\n%s", err, p.output())
|
||||
default:
|
||||
}
|
||||
if err := p.command.Process.Signal(syscall.Signal(0)); err != nil {
|
||||
t.Fatalf("blocked process is not alive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ptyProcess) waitForExit(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-p.done:
|
||||
if err != nil {
|
||||
t.Fatalf("PTY process exited with error: %v\n%s", err, p.output())
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("timed out waiting for PTY process exit\n%s", p.output())
|
||||
}
|
||||
_ = p.terminal.Close()
|
||||
select {
|
||||
case <-p.readDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("PTY output reader did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func isolatedPTYEnvironment(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
for _, path := range []string{filepath.Join(root, "home"), filepath.Join(root, "config"), filepath.Join(root, "state")} {
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return append(isolatedProcessEnvironment(root), "TERM=xterm-256color", "NO_COLOR=1")
|
||||
}
|
||||
|
||||
func waitForPath(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(5 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("inspect synchronization path: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-timer.C:
|
||||
t.Fatalf("timed out waiting for synchronization path %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertAlternateScreenRestored(t *testing.T, output string) {
|
||||
t.Helper()
|
||||
enter := strings.Index(output, "\x1b[?1049h")
|
||||
leave := strings.LastIndex(output, "\x1b[?1049l")
|
||||
if enter < 0 || leave <= enter {
|
||||
t.Fatalf("alternate screen was not entered and restored in order: %q", output)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user