148 lines
3.9 KiB
Go
148 lines
3.9 KiB
Go
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"g.fc.run/misc/byre/internal/api"
|
|
)
|
|
|
|
type termConn struct {
|
|
t *testing.T
|
|
conn *websocket.Conn
|
|
seen strings.Builder
|
|
}
|
|
|
|
func (h *harness) openTerminal(taskID string) *termConn {
|
|
h.t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
header := http.Header{}
|
|
header.Set("Tailscale-User-Login", login)
|
|
header.Set("Tailscale-User-Name", "Alice")
|
|
url := "ws" + strings.TrimPrefix(h.url, "http") + "/api/tasks/" + taskID + "/terminal?cols=80&rows=24"
|
|
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: header})
|
|
if err != nil {
|
|
h.t.Fatalf("dial terminal: %v", err)
|
|
}
|
|
h.t.Cleanup(func() { conn.CloseNow() })
|
|
return &termConn{t: h.t, conn: conn}
|
|
}
|
|
|
|
func (c *termConn) send(text string) {
|
|
c.t.Helper()
|
|
err := c.conn.Write(context.Background(), websocket.MessageBinary, []byte(text))
|
|
if err != nil {
|
|
c.t.Fatalf("write terminal: %v", err)
|
|
}
|
|
}
|
|
|
|
func (c *termConn) waitFor(want string) {
|
|
c.t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
for !strings.Contains(c.seen.String(), want) {
|
|
_, b, err := c.conn.Read(ctx)
|
|
if err != nil {
|
|
c.t.Fatalf("waiting for %q: %v; seen %q", want, err, c.seen.String())
|
|
}
|
|
c.seen.Write(b)
|
|
}
|
|
}
|
|
|
|
func (c *termConn) waitClosed() websocket.CloseError {
|
|
c.t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
for {
|
|
_, b, err := c.conn.Read(ctx)
|
|
if err == nil {
|
|
c.seen.Write(b)
|
|
continue
|
|
}
|
|
ce := websocket.CloseError{}
|
|
if !errors.As(err, &ce) {
|
|
c.t.Fatalf("want a close frame, got %v", err)
|
|
}
|
|
return ce
|
|
}
|
|
}
|
|
|
|
func TestTaskTerminal(t *testing.T) {
|
|
t.Setenv("SHELL", "/bin/sh")
|
|
h := newHarness(t)
|
|
p := h.createProject("demo")
|
|
task := api.Task{}
|
|
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "terminal"}, &task)
|
|
|
|
wt, err := filepath.EvalSymlinks(task.WorktreePath)
|
|
if err != nil {
|
|
t.Fatalf("resolve worktree: %v", err)
|
|
}
|
|
header := http.Header{}
|
|
header.Set("Tailscale-User-Login", login)
|
|
header.Set("Tailscale-User-Name", "Alice")
|
|
header.Set("Origin", "https://elsewhere.example")
|
|
_, resp, err := websocket.Dial(context.Background(), "ws"+strings.TrimPrefix(h.url, "http")+"/api/tasks/"+task.Id+"/terminal?cols=80&rows=24", &websocket.DialOptions{HTTPHeader: header})
|
|
if err == nil || resp == nil || resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("cross-origin terminal: want 403, got %v %v", resp, err)
|
|
}
|
|
|
|
a := h.openTerminal(task.Id)
|
|
a.send("echo \"$PORT:$(pwd -P)\"\n")
|
|
a.waitFor(fmt.Sprintf("%d:%s", task.PortBase, wt))
|
|
a.send("echo byre-$((40+2))\n")
|
|
a.waitFor("byre-42")
|
|
|
|
size, err := json.Marshal(api.TerminalSize{Cols: 100, Rows: 30})
|
|
if err != nil {
|
|
t.Fatalf("marshal size: %v", err)
|
|
}
|
|
err = a.conn.Write(context.Background(), websocket.MessageText, size)
|
|
if err != nil {
|
|
t.Fatalf("resize: %v", err)
|
|
}
|
|
a.send("stty size\n")
|
|
a.waitFor("30 100")
|
|
|
|
b := h.openTerminal(task.Id)
|
|
b.waitFor("byre-42")
|
|
b.send("exit\n")
|
|
for _, c := range []*termConn{a, b} {
|
|
ce := c.waitClosed()
|
|
if ce.Code != 4000 || ce.Reason != "exit status 0" {
|
|
t.Fatalf("close after exit: %d %q", ce.Code, ce.Reason)
|
|
}
|
|
}
|
|
|
|
c := h.openTerminal(task.Id)
|
|
c.send("echo fresh\n")
|
|
c.waitFor("fresh")
|
|
if strings.Contains(c.seen.String(), "byre-42") {
|
|
t.Fatalf("new shell replayed the old one's output: %q", c.seen.String())
|
|
}
|
|
|
|
code := h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("discard: %d", code)
|
|
}
|
|
ce := c.waitClosed()
|
|
if ce.Code != 4000 {
|
|
t.Fatalf("close after discard: %d %q", ce.Code, ce.Reason)
|
|
}
|
|
|
|
code = h.do("GET", "/api/tasks/"+task.Id+"/terminal?cols=80&rows=24", nil, nil)
|
|
if code != http.StatusConflict {
|
|
t.Fatalf("terminal of a discarded task: want 409, got %d", code)
|
|
}
|
|
}
|