332 lines
8.9 KiB
Go
332 lines
8.9 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hjbdev/patterm/internal/protocol"
|
|
)
|
|
|
|
func TestDaemonDetachReattachPreservesProcess(t *testing.T) {
|
|
root := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
|
|
t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data"))
|
|
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
|
|
projectDir := filepath.Join(root, "project")
|
|
if err := os.MkdirAll(projectDir, 0o700); err != nil {
|
|
t.Fatalf("mkdir project: %v", err)
|
|
}
|
|
socket := filepath.Join(root, "runtime", "patterm", "daemon.sock")
|
|
pid := filepath.Join(root, "runtime", "patterm", "daemon.pid")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- RunDaemon(ctx, DaemonOptions{
|
|
ProjectDir: projectDir,
|
|
SocketPath: socket,
|
|
PidPath: pid,
|
|
Cols: 80,
|
|
Rows: 24,
|
|
})
|
|
}()
|
|
waitForSocket(t, socket, errCh)
|
|
|
|
client1 := dialDaemon(t, socket)
|
|
sendFrame(t, client1, protocol.FrameAttach, protocol.Attach{
|
|
ProjectPath: projectDir,
|
|
TermSize: protocol.Size{Cols: 80, Rows: 24},
|
|
})
|
|
expectFrame(t, client1, protocol.FrameHello)
|
|
expectFrame(t, client1, protocol.FrameProjectList)
|
|
expectFrame(t, client1, protocol.FrameChrome)
|
|
|
|
data, _ := json.Marshal(map[string]any{
|
|
"argv": []string{"sh", "-c", "trap 'exit 0' TERM; while :; do echo STILL-HERE; sleep 1; done"},
|
|
"name": "survivor",
|
|
})
|
|
sendFrame(t, client1, protocol.FramePaletteCommand, protocol.PaletteCommand{
|
|
Kind: "spawn_command",
|
|
Data: data,
|
|
})
|
|
waitForLifecycle(t, client1, protocol.LifecycleSpawned, 3*time.Second)
|
|
sendFrame(t, client1, protocol.FrameDetach, protocol.Detach{})
|
|
_ = client1.Close()
|
|
|
|
client2 := dialDaemon(t, socket)
|
|
defer client2.Close()
|
|
sendFrame(t, client2, protocol.FrameAttach, protocol.Attach{
|
|
ProjectPath: projectDir,
|
|
TermSize: protocol.Size{Cols: 80, Rows: 24},
|
|
})
|
|
expectFrame(t, client2, protocol.FrameHello)
|
|
expectFrame(t, client2, protocol.FrameProjectList)
|
|
chrome := expectChrome(t, client2)
|
|
if !chromeHasProcess(chrome, "survivor") {
|
|
t.Fatalf("reattached chrome did not include surviving process: %s", string(chrome.Model))
|
|
}
|
|
expectFrame(t, client2, protocol.FramePaneSnapshot)
|
|
|
|
cancel()
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
t.Fatalf("daemon returned error: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatalf("daemon did not stop")
|
|
}
|
|
}
|
|
|
|
func TestDaemonTCPTokenAuthAndUnixExemption(t *testing.T) {
|
|
root := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
|
|
t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data"))
|
|
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
|
|
projectDir := filepath.Join(root, "project")
|
|
if err := os.MkdirAll(projectDir, 0o700); err != nil {
|
|
t.Fatalf("mkdir project: %v", err)
|
|
}
|
|
socket := filepath.Join(root, "runtime", "patterm", "daemon.sock")
|
|
pid := filepath.Join(root, "runtime", "patterm", "daemon.pid")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
errCh := make(chan error, 1)
|
|
ready := make(chan string, 1)
|
|
go func() {
|
|
errCh <- RunDaemon(ctx, DaemonOptions{
|
|
ProjectDir: projectDir,
|
|
SocketPath: socket,
|
|
PidPath: pid,
|
|
ListenAddr: "127.0.0.1:0",
|
|
Token: "secret-token",
|
|
TokenOut: io.Discard,
|
|
ListenReady: ready,
|
|
Cols: 80,
|
|
Rows: 24,
|
|
})
|
|
}()
|
|
waitForSocket(t, socket, errCh)
|
|
tcpAddr := waitForTCPAddr(t, ready, errCh)
|
|
|
|
assertTCPAttachDenied(t, tcpAddr, "")
|
|
assertTCPAttachDenied(t, tcpAddr, "wrong-token")
|
|
|
|
tcpClient := dialTCPDaemon(t, tcpAddr)
|
|
defer tcpClient.Close()
|
|
sendFrame(t, tcpClient, protocol.FrameAttach, protocol.Attach{
|
|
Token: "secret-token",
|
|
ProjectPath: projectDir,
|
|
TermSize: protocol.Size{Cols: 80, Rows: 24},
|
|
})
|
|
expectFrame(t, tcpClient, protocol.FrameHello)
|
|
expectFrame(t, tcpClient, protocol.FrameProjectList)
|
|
expectFrame(t, tcpClient, protocol.FrameChrome)
|
|
data, _ := json.Marshal(map[string]any{
|
|
"argv": []string{"sh", "-c", "trap 'exit 0' TERM; echo TCP-SNAPSHOT; sleep 30"},
|
|
"name": "tcp-survivor",
|
|
})
|
|
sendFrame(t, tcpClient, protocol.FramePaletteCommand, protocol.PaletteCommand{
|
|
Kind: "spawn_command",
|
|
Data: data,
|
|
})
|
|
expectFrame(t, tcpClient, protocol.FramePaneSnapshot)
|
|
|
|
unixClient := dialDaemon(t, socket)
|
|
defer unixClient.Close()
|
|
sendFrame(t, unixClient, protocol.FrameAttach, protocol.Attach{
|
|
ProjectPath: projectDir,
|
|
TermSize: protocol.Size{Cols: 80, Rows: 24},
|
|
})
|
|
expectFrame(t, unixClient, protocol.FrameHello)
|
|
|
|
cancel()
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
t.Fatalf("daemon returned error: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatalf("daemon did not stop")
|
|
}
|
|
}
|
|
|
|
func waitForSocket(t *testing.T, socket string, errCh <-chan error) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
return
|
|
}
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil && strings.Contains(err.Error(), "operation not permitted") {
|
|
t.Skipf("unix sockets unavailable in this sandbox: %v", err)
|
|
}
|
|
t.Fatalf("daemon exited before creating socket: %v", err)
|
|
default:
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatalf("socket %s was not created", socket)
|
|
}
|
|
|
|
func dialDaemon(t *testing.T, socket string) protocol.Transport {
|
|
t.Helper()
|
|
conn, err := net.Dial("unix", socket)
|
|
if err != nil {
|
|
t.Fatalf("dial daemon: %v", err)
|
|
}
|
|
return protocol.NewConnTransport(conn)
|
|
}
|
|
|
|
func dialTCPDaemon(t *testing.T, addr string) protocol.Transport {
|
|
t.Helper()
|
|
conn, err := net.Dial("tcp", addr)
|
|
if err != nil {
|
|
t.Fatalf("dial tcp daemon: %v", err)
|
|
}
|
|
return protocol.NewConnTransport(conn)
|
|
}
|
|
|
|
func waitForTCPAddr(t *testing.T, ready <-chan string, errCh <-chan error) string {
|
|
t.Helper()
|
|
select {
|
|
case addr := <-ready:
|
|
return addr
|
|
case err := <-errCh:
|
|
if err != nil && strings.Contains(err.Error(), "operation not permitted") {
|
|
t.Skipf("tcp sockets unavailable in this sandbox: %v", err)
|
|
}
|
|
t.Fatalf("daemon exited before TCP listener was ready: %v", err)
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatalf("tcp listener was not ready")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func assertTCPAttachDenied(t *testing.T, addr, token string) {
|
|
t.Helper()
|
|
client := dialTCPDaemon(t, addr)
|
|
defer client.Close()
|
|
sendFrame(t, client, protocol.FrameAttach, protocol.Attach{
|
|
Token: token,
|
|
TermSize: protocol.Size{Cols: 80, Rows: 24},
|
|
})
|
|
f := expectFrame(t, client, protocol.FrameError)
|
|
msg, err := protocol.Decode[protocol.Error](f)
|
|
if err != nil {
|
|
t.Fatalf("decode error frame: %v", err)
|
|
}
|
|
if !strings.Contains(msg.Message, "auth denied") {
|
|
t.Fatalf("error message = %q, want auth denied", msg.Message)
|
|
}
|
|
}
|
|
|
|
func sendFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) {
|
|
t.Helper()
|
|
f, err := protocol.NewFrame(typ, payload)
|
|
if err != nil {
|
|
t.Fatalf("frame %s: %v", typ, err)
|
|
}
|
|
if err := tr.Send(f); err != nil {
|
|
t.Fatalf("send %s: %v", typ, err)
|
|
}
|
|
}
|
|
|
|
func expectFrame(t *testing.T, tr protocol.Transport, typ protocol.FrameType) protocol.Frame {
|
|
t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
|
|
if !ok {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("recv %s: %v", typ, err)
|
|
}
|
|
if f.Type == typ {
|
|
return f
|
|
}
|
|
}
|
|
t.Fatalf("frame %s not received", typ)
|
|
return protocol.Frame{}
|
|
}
|
|
|
|
func expectChrome(t *testing.T, tr protocol.Transport) protocol.Chrome {
|
|
t.Helper()
|
|
f := expectFrame(t, tr, protocol.FrameChrome)
|
|
chrome, err := protocol.Decode[protocol.Chrome](f)
|
|
if err != nil {
|
|
t.Fatalf("decode chrome: %v", err)
|
|
}
|
|
return chrome
|
|
}
|
|
|
|
func waitForLifecycle(t *testing.T, tr protocol.Transport, kind protocol.LifecycleKind, timeout time.Duration) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
|
|
if !ok {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("recv lifecycle: %v", err)
|
|
}
|
|
if f.Type != protocol.FrameLifecycle {
|
|
continue
|
|
}
|
|
msg, err := protocol.Decode[protocol.Lifecycle](f)
|
|
if err != nil {
|
|
t.Fatalf("decode lifecycle: %v", err)
|
|
}
|
|
if msg.Kind == kind {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("lifecycle %s not received", kind)
|
|
}
|
|
|
|
func recvFrameWithin(tr protocol.Transport, timeout time.Duration) (protocol.Frame, error, bool) {
|
|
type result struct {
|
|
f protocol.Frame
|
|
err error
|
|
}
|
|
ch := make(chan result, 1)
|
|
go func() {
|
|
f, err := tr.Recv()
|
|
ch <- result{f: f, err: err}
|
|
}()
|
|
select {
|
|
case r := <-ch:
|
|
return r.f, r.err, true
|
|
case <-time.After(timeout):
|
|
return protocol.Frame{}, nil, false
|
|
}
|
|
}
|
|
|
|
func chromeHasProcess(chrome protocol.Chrome, name string) bool {
|
|
var model struct {
|
|
Processes []childModel `json:"processes"`
|
|
}
|
|
if err := json.Unmarshal(chrome.Model, &model); err != nil {
|
|
return false
|
|
}
|
|
for _, p := range model.Processes {
|
|
if p.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|