termd/internal/server/integration_test.go
2026-07-02 22:13:49 -03:00

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()
}