499 lines
14 KiB
Go
499 lines
14 KiB
Go
// Full-stack integration tests: a real HTTP server wrapping the real
|
|
// registry, real PTYs and the real emulator, driven through the CLI command
|
|
// functions — so one test crosses CLI parsing → REST client → routing →
|
|
// handlers → session locking → keymap → vt encoding → PTY → probe → emulator
|
|
// → screen snapshot.
|
|
//
|
|
// The only fake is the "external service": the TUI application inside the
|
|
// PTY. That's the probe — this same test binary re-executed with
|
|
// TERMD_TEST_PROBE=1 — which sets its tty raw, enables the DEC modes listed
|
|
// in PROBE_MODES, and echoes every byte it receives back as hex on screen,
|
|
// so tests can assert both directions of the pipe exactly.
|
|
package server_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/term"
|
|
|
|
"termd/internal/api"
|
|
"termd/internal/cli"
|
|
"termd/internal/client"
|
|
"termd/internal/server"
|
|
"termd/internal/session"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
if os.Getenv("TERMD_TEST_PROBE") == "1" {
|
|
probeMain()
|
|
return
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// probeMain is the deterministic fake TUI.
|
|
func probeMain() {
|
|
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
|
if err != nil {
|
|
fmt.Printf("PROBE ERROR: MakeRaw: %v\r\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer term.Restore(int(os.Stdin.Fd()), oldState) //nolint:errcheck
|
|
|
|
for _, mode := range strings.Split(os.Getenv("PROBE_MODES"), ",") {
|
|
if mode != "" {
|
|
fmt.Printf("\x1b[?%sh", mode)
|
|
}
|
|
}
|
|
fmt.Print("PROBE READY\r\n")
|
|
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := os.Stdin.Read(buf)
|
|
if n > 0 {
|
|
var line strings.Builder
|
|
for i, b := range buf[:n] {
|
|
if i > 0 {
|
|
line.WriteByte(' ')
|
|
}
|
|
fmt.Fprintf(&line, "%02x", b)
|
|
}
|
|
fmt.Print(line.String() + "\r\n")
|
|
// Control bytes for tests: R disables mouse reporting, Q leaves
|
|
// the alt screen and exits cleanly.
|
|
if bytes.ContainsRune(buf[:n], 'R') {
|
|
fmt.Print("\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l")
|
|
}
|
|
if bytes.ContainsRune(buf[:n], 'Q') {
|
|
fmt.Print("\x1b[?1049l")
|
|
os.Exit(0)
|
|
}
|
|
}
|
|
if err != nil {
|
|
os.Exit(0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// env is one test's daemon: real registry, real HTTP server, real client.
|
|
type env struct {
|
|
t *testing.T
|
|
c *client.Client
|
|
reg *session.Registry
|
|
srv *httptest.Server
|
|
}
|
|
|
|
func newEnv(t *testing.T) *env {
|
|
t.Helper()
|
|
reg := session.NewRegistry()
|
|
srv := httptest.NewServer(server.New(reg, ""))
|
|
t.Cleanup(func() {
|
|
for _, s := range reg.List() {
|
|
_ = s.Kill(syscall.SIGKILL)
|
|
}
|
|
srv.Close()
|
|
})
|
|
return &env{t: t, c: client.New(srv.URL, ""), reg: reg, srv: srv}
|
|
}
|
|
|
|
func ctx() context.Context { return context.Background() }
|
|
|
|
// run executes a CLI command function and returns its output.
|
|
func (e *env) run(fn func(context.Context, *client.Client, []string, io.Writer) error, args ...string) string {
|
|
e.t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := fn(ctx(), e.c, args, &buf); err != nil {
|
|
e.t.Fatalf("cli command %v: %v", args, err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// newSession creates a session through the CLI and returns its id.
|
|
func (e *env) newSession(args ...string) string {
|
|
e.t.Helper()
|
|
return strings.TrimSpace(e.run(cli.New, args...))
|
|
}
|
|
|
|
// newProbe spawns the probe with the given DEC modes and waits until it has
|
|
// initialized.
|
|
func (e *env) newProbe(modes string) string {
|
|
e.t.Helper()
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
id := e.newSession("-env", "TERMD_TEST_PROBE=1", "-env", "PROBE_MODES="+modes, exe)
|
|
e.waitFor("probe ready", func() bool {
|
|
return strings.Contains(e.screenText(id), "PROBE READY")
|
|
})
|
|
return id
|
|
}
|
|
|
|
func (e *env) screen(id string) api.Screen {
|
|
e.t.Helper()
|
|
scr, err := e.c.Screen(ctx(), id, false)
|
|
if err != nil {
|
|
e.t.Fatalf("screen %s: %v", id, err)
|
|
}
|
|
return scr
|
|
}
|
|
|
|
func (e *env) screenText(id string) string {
|
|
e.t.Helper()
|
|
return strings.Join(e.screen(id).Lines, "\n")
|
|
}
|
|
|
|
func (e *env) send(id string, args ...string) {
|
|
e.t.Helper()
|
|
e.run(cli.Send, append([]string{id}, args...)...)
|
|
}
|
|
|
|
func (e *env) waitFor(desc string, cond func() bool) {
|
|
e.t.Helper()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if cond() {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
e.t.Fatalf("timed out waiting for %s", desc)
|
|
}
|
|
|
|
// probeHex extracts the hex byte stream the probe has echoed so far.
|
|
func (e *env) probeHex(id string) string {
|
|
var toks []string
|
|
for _, line := range e.screen(id).Lines {
|
|
if strings.Contains(line, "PROBE") {
|
|
continue
|
|
}
|
|
for _, f := range strings.Fields(line) {
|
|
if len(f) == 2 && isHex(f) {
|
|
toks = append(toks, f)
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(toks, "")
|
|
}
|
|
|
|
func isHex(s string) bool {
|
|
for _, c := range s {
|
|
if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func hexOf(s string) string {
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
fmt.Fprintf(&b, "%02x", s[i])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
e := newEnv(t)
|
|
resp, err := http.Get(e.srv.URL + "/healthz")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != 200 || !strings.Contains(string(body), `"ok":true`) {
|
|
t.Fatalf("healthz: %d %s", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
func TestSessionLifecycle(t *testing.T) {
|
|
e := newEnv(t)
|
|
|
|
// A session whose process exits keeps its screen queryable and reports
|
|
// the exit code.
|
|
id := e.newSession("sh", "-c", "echo done; exit 3")
|
|
e.waitFor("exit code 3", func() bool {
|
|
info, err := e.c.Info(ctx(), id)
|
|
return err == nil && info.Exited && info.ExitCode != nil && *info.ExitCode == 3
|
|
})
|
|
if !strings.Contains(e.screenText(id), "done") {
|
|
t.Errorf("screen after exit lost output:\n%s", e.screenText(id))
|
|
}
|
|
if out := e.run(cli.Ls); !strings.Contains(out, "exited(3)") {
|
|
t.Errorf("ls does not show exit state:\n%s", out)
|
|
}
|
|
|
|
// Input into an exited session is rejected loudly.
|
|
if err := cli.Send(ctx(), e.c, []string{id, "nope"}, io.Discard); err == nil ||
|
|
!strings.Contains(err.Error(), "exited") {
|
|
t.Errorf("input to exited session: err = %v, want 'exited'", err)
|
|
}
|
|
|
|
// Kill removes it; a live process actually dies.
|
|
e.run(cli.Kill, id)
|
|
if _, err := e.c.Info(ctx(), id); err == nil || !strings.Contains(err.Error(), "404") {
|
|
t.Errorf("info after kill: err = %v, want 404", err)
|
|
}
|
|
|
|
id2 := e.newSession("sleep", "100")
|
|
info, err := e.c.Info(ctx(), id2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.run(cli.Kill, id2)
|
|
e.waitFor("process gone", func() bool {
|
|
return syscall.Kill(info.PID, 0) != nil
|
|
})
|
|
if out := e.run(cli.Ls); strings.Contains(out, id2) {
|
|
t.Errorf("killed session still listed:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestShellEchoThroughCLI(t *testing.T) {
|
|
e := newEnv(t)
|
|
id := e.newSession("-cols", "60", "-rows", "10", "sh")
|
|
e.send(id, "echo hello-$((6*7))", "Enter")
|
|
e.waitFor("echo output", func() bool {
|
|
return strings.Contains(e.screenText(id), "hello-42")
|
|
})
|
|
scr := e.screen(id)
|
|
if scr.Cols != 60 || scr.Rows != 10 || len(scr.Lines) != 10 {
|
|
t.Errorf("screen dims: %dx%d, %d lines", scr.Cols, scr.Rows, len(scr.Lines))
|
|
}
|
|
if scr.Cursor.Y == 0 {
|
|
t.Errorf("cursor did not advance: %+v", scr.Cursor)
|
|
}
|
|
}
|
|
|
|
func TestNamedKeysReachApplication(t *testing.T) {
|
|
e := newEnv(t)
|
|
// DECCKM on (?1), so arrows must arrive in application form.
|
|
id := e.newProbe("1")
|
|
|
|
e.send(id, "Up", "C-Up", "C-c", "F5", "hi", "BTab")
|
|
want := hexOf("\x1bOA" + "\x1b[1;5A" + "\x03" + "\x1b[15~" + "hi" + "\x1b[Z")
|
|
e.waitFor("all key bytes to reach the probe", func() bool {
|
|
return e.probeHex(id) == want
|
|
})
|
|
|
|
// Raw input bytes pass through unmodified, ordered with keys.
|
|
if _, err := e.c.Input(ctx(), id, []api.InputItem{
|
|
{Raw: strPtr("\x1b[200~paste\x1b[201~")},
|
|
{Key: strPtr("Enter")},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want += hexOf("\x1b[200~paste\x1b[201~\r")
|
|
e.waitFor("raw bytes to reach the probe", func() bool {
|
|
return e.probeHex(id) == want
|
|
})
|
|
}
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
func TestModeTrackingLifecycle(t *testing.T) {
|
|
e := newEnv(t)
|
|
id := e.newProbe("1049,1,1003,1006,2004")
|
|
|
|
scr := e.screen(id)
|
|
if !scr.AltScreen {
|
|
t.Error("alt_screen not reported")
|
|
}
|
|
m := scr.Modes
|
|
if m.Mouse != "any_event" || m.MouseEncoding != "sgr" || !m.AppCursorKeys || !m.BracketedPaste {
|
|
t.Errorf("modes wrong: %+v", m)
|
|
}
|
|
|
|
// Probe drops mouse reporting on R.
|
|
e.send(id, "-l", "R")
|
|
e.waitFor("mouse modes dropped", func() bool {
|
|
return e.screen(id).Modes.Mouse == "off"
|
|
})
|
|
|
|
// Probe leaves the alt screen and exits on Q.
|
|
e.send(id, "-l", "Q")
|
|
e.waitFor("probe exit", func() bool {
|
|
scr := e.screen(id)
|
|
return scr.Exited && !scr.AltScreen && scr.ExitCode != nil && *scr.ExitCode == 0
|
|
})
|
|
}
|
|
|
|
func TestMouseGatingAndDelivery(t *testing.T) {
|
|
e := newEnv(t)
|
|
|
|
// No mouse reporting → 409, loudly, with the modes attached.
|
|
plain := e.newSession("sh")
|
|
err := cli.Mouse(ctx(), e.c, []string{plain, "click", "5,3"}, io.Discard)
|
|
if err == nil || !strings.Contains(err.Error(), "409") || !strings.Contains(err.Error(), "mouse=off") {
|
|
t.Errorf("click without mouse mode: err = %v, want 409 with modes", err)
|
|
}
|
|
|
|
// ?1002 + ?1006: click arrives as exact SGR press+release bytes.
|
|
id := e.newProbe("1002,1006")
|
|
e.run(cli.Mouse, id, "click", "10,5")
|
|
want := hexOf("\x1b[<0;11;6M" + "\x1b[<0;11;6m")
|
|
e.waitFor("SGR click bytes", func() bool { return e.probeHex(id) == want })
|
|
|
|
// Drag is fine under ?1002 (motion+32 in the button bits)...
|
|
e.run(cli.Mouse, id, "drag", "11,5")
|
|
want += hexOf("\x1b[<32;12;6M")
|
|
e.waitFor("SGR drag bytes", func() bool { return e.probeHex(id) == want })
|
|
|
|
// ...but bare motion needs ?1003.
|
|
err = cli.Mouse(ctx(), e.c, []string{id, "move", "12,5"}, io.Discard)
|
|
if err == nil || !strings.Contains(err.Error(), "1003") {
|
|
t.Errorf("move under ?1002: err = %v, want 1003 explanation", err)
|
|
}
|
|
|
|
// Scroll defaults to wheel-up; modifiers are encoded.
|
|
e.run(cli.Mouse, id, "scroll", "0,0")
|
|
want += hexOf("\x1b[<64;1;1M")
|
|
e.waitFor("SGR wheel bytes", func() bool { return e.probeHex(id) == want })
|
|
|
|
e.run(cli.Mouse, "-mod", "ctrl", id, "click", "0,0")
|
|
want += hexOf("\x1b[<16;1;1M" + "\x1b[<16;1;1m")
|
|
e.waitFor("ctrl-click bytes", func() bool { return e.probeHex(id) == want })
|
|
|
|
// Only ?1000: drag must be rejected.
|
|
normal := e.newProbe("1000,1006")
|
|
err = cli.Mouse(ctx(), e.c, []string{normal, "drag", "1,1"}, io.Discard)
|
|
if err == nil || !strings.Contains(err.Error(), "1002") {
|
|
t.Errorf("drag under ?1000: err = %v, want 1002 explanation", err)
|
|
}
|
|
}
|
|
|
|
func TestResize(t *testing.T) {
|
|
e := newEnv(t)
|
|
id := e.newSession("sh")
|
|
e.run(cli.Resize, id, "100x20")
|
|
scr := e.screen(id)
|
|
if scr.Cols != 100 || scr.Rows != 20 {
|
|
t.Fatalf("screen size after resize: %dx%d", scr.Cols, scr.Rows)
|
|
}
|
|
// The child's tty must agree — stty reads the PTY, not our emulator.
|
|
e.send(id, "stty size", "Enter")
|
|
e.waitFor("stty output", func() bool {
|
|
return strings.Contains(e.screenText(id), "20 100")
|
|
})
|
|
}
|
|
|
|
func TestInputValidation(t *testing.T) {
|
|
e := newEnv(t)
|
|
id := e.newSession("sh")
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
items []api.InputItem
|
|
want string
|
|
}{
|
|
{"unknown key", []api.InputItem{{Key: strPtr("NoSuchKey")}}, "NoSuchKey"},
|
|
{"empty", nil, "empty"},
|
|
{"two fields", []api.InputItem{{Text: strPtr("a"), Key: strPtr("b")}}, "exactly one"},
|
|
{"no fields", []api.InputItem{{}}, "exactly one"},
|
|
} {
|
|
_, err := e.c.Input(ctx(), id, tc.items)
|
|
if err == nil || !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("%s: err = %v, want 400 mentioning %q", tc.name, err, tc.want)
|
|
}
|
|
}
|
|
|
|
// Nothing from a rejected batch may be delivered: the screen stays quiet.
|
|
if _, err := e.c.Input(ctx(), id, []api.InputItem{
|
|
{Text: strPtr("echo LEAKED")},
|
|
{Key: strPtr("BogusKey")},
|
|
}); err == nil {
|
|
t.Fatal("batch with bad key accepted")
|
|
}
|
|
e.send(id, "echo marker", "Enter")
|
|
e.waitFor("marker", func() bool { return strings.Contains(e.screenText(id), "marker") })
|
|
if strings.Contains(e.screenText(id), "LEAKED") {
|
|
t.Errorf("rejected batch partially delivered:\n%s", e.screenText(id))
|
|
}
|
|
}
|
|
|
|
func TestScreenFormats(t *testing.T) {
|
|
e := newEnv(t)
|
|
id := e.newSession("sh")
|
|
e.send(id, "printf '\\033[31mred\\033[0m\\n'", "Enter")
|
|
e.waitFor("red output", func() bool { return strings.Contains(e.screenText(id), "red") })
|
|
|
|
scr, err := e.c.Screen(ctx(), id, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if scr.Raw == "" || !strings.Contains(scr.Raw, "red") {
|
|
t.Errorf("raw format missing styled content")
|
|
}
|
|
if plain := e.screen(id); plain.Raw != "" {
|
|
t.Errorf("text format unexpectedly carries raw payload")
|
|
}
|
|
|
|
resp, err := http.Get(e.srv.URL + "/sessions/" + id + "/screen?format=nope")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 400 {
|
|
t.Errorf("bad format: status %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAuthToken(t *testing.T) {
|
|
reg := session.NewRegistry()
|
|
srv := httptest.NewServer(server.New(reg, "sekrit"))
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Get(srv.URL + "/healthz")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Fatalf("no token: status %d, want 401", resp.StatusCode)
|
|
}
|
|
|
|
authed := client.New(srv.URL, "sekrit")
|
|
if _, err := authed.List(ctx()); err != nil {
|
|
t.Fatalf("with token: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestConcurrentScreenAndInput hammers a flooding session from many
|
|
// goroutines; the race detector owns the assertions here.
|
|
func TestConcurrentScreenAndInput(t *testing.T) {
|
|
e := newEnv(t)
|
|
id := e.newSession("sh", "-c", "while :; do echo spam; done")
|
|
e.waitFor("flood running", func() bool {
|
|
return strings.Contains(e.screenText(id), "spam")
|
|
})
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < 25; j++ {
|
|
if _, err := e.c.Screen(ctx(), id, j%2 == 0); err != nil {
|
|
t.Errorf("screen: %v", err)
|
|
return
|
|
}
|
|
if _, err := e.c.Input(ctx(), id, []api.InputItem{{Text: strPtr("x")}}); err != nil {
|
|
t.Errorf("input: %v", err)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|