package remotecontrol import ( "context" "net/http" "net/http/cookiejar" "strings" "sync" "testing" "time" "github.com/coder/websocket" "github.com/coder/websocket/wsjson" ) type fakeHandler struct { mu sync.Mutex inputs []string decides []decision } type decision struct { id string approve bool always bool } func (h *fakeHandler) OnInput(text string) { h.mu.Lock() defer h.mu.Unlock() h.inputs = append(h.inputs, text) } func (h *fakeHandler) OnDecide(id string, approve, always bool) { h.mu.Lock() defer h.mu.Unlock() h.decides = append(h.decides, decision{id, approve, always}) } func (h *fakeHandler) lastInput() string { h.mu.Lock() defer h.mu.Unlock() if len(h.inputs) == 0 { return "" } return h.inputs[len(h.inputs)-1] } // startTestServer boots a server on loopback and returns it plus the pairing // URL. The caller must Stop it. func startTestServer(t *testing.T, h Handler) (*Server, string) { t.Helper() s := NewServer(Config{Host: "127.0.0.1", Port: 0}, h) url, err := s.Start() if err != nil { t.Fatalf("Start: %v", err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = s.Stop(ctx) }) return s, url } func TestHealthz(t *testing.T) { _, pairURL := startTestServer(t, nil) base := pairURL[:strings.Index(pairURL, "/pair")] resp, err := http.Get(base + "/healthz") if err != nil { t.Fatalf("get healthz: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("healthz status = %d, want 200", resp.StatusCode) } } func TestPairRejectsBadToken(t *testing.T) { _, pairURL := startTestServer(t, nil) base := pairURL[:strings.Index(pairURL, "/pair")] resp, err := http.Get(base + "/pair?t=bogus") if err != nil { t.Fatalf("get pair: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("bad-token status = %d, want 401", resp.StatusCode) } } func TestPairSetsCookieAndServesSPA(t *testing.T) { _, pairURL := startTestServer(t, nil) jar, _ := cookiejar.New(nil) client := &http.Client{Jar: jar} resp, err := client.Get(pairURL) if err != nil { t.Fatalf("pair: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { // followed redirect to / t.Fatalf("pair->root status = %d, want 200", resp.StatusCode) } // A second use of the same one-time token must fail. resp2, err := http.Get(pairURL) if err != nil { t.Fatalf("pair reuse: %v", err) } defer resp2.Body.Close() if resp2.StatusCode != http.StatusUnauthorized { t.Fatalf("token reuse status = %d, want 401", resp2.StatusCode) } } // The session cookie must be SameSite=Lax so mobile browsers keep it across the // QR-scan /pair→/ redirect (Strict is dropped by some, breaking pairing). func TestPairCookieIsSameSiteLax(t *testing.T) { _, pairURL := startTestServer(t, nil) client := &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse // stop at the 302 to read Set-Cookie }, } resp, err := client.Get(pairURL) if err != nil { t.Fatalf("pair: %v", err) } defer resp.Body.Close() var got *http.Cookie for _, c := range resp.Cookies() { if c.Name == cookieName { got = c } } if got == nil { t.Fatal("no session cookie issued") } if got.SameSite != http.SameSiteLaxMode { t.Fatalf("SameSite = %v, want Lax", got.SameSite) } } func TestRootRequiresAuth(t *testing.T) { _, pairURL := startTestServer(t, nil) base := pairURL[:strings.Index(pairURL, "/pair")] resp, err := http.Get(base + "/") if err != nil { t.Fatalf("get root: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("unauth root status = %d, want 401", resp.StatusCode) } } // sessionCred pairs and extracts the pigo_rc cookie value for WS dialing. func sessionCred(t *testing.T, pairURL string) (base, cred string) { t.Helper() jar, _ := cookiejar.New(nil) client := &http.Client{ Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse // stop at the 302 to read the cookie }, } resp, err := client.Get(pairURL) if err != nil { t.Fatalf("pair: %v", err) } defer resp.Body.Close() for _, c := range resp.Cookies() { if c.Name == cookieName { cred = c.Value } } if cred == "" { t.Fatal("no session cookie issued") } return pairURL[:strings.Index(pairURL, "/pair")], cred } func dialWS(t *testing.T, base, cred string) (*websocket.Conn, *http.Response, error) { t.Helper() wsURL := "ws" + strings.TrimPrefix(base, "http") + "/ws" opts := &websocket.DialOptions{ HTTPHeader: http.Header{"Cookie": []string{cookieName + "=" + cred}}, } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() return websocket.Dial(ctx, wsURL, opts) } func TestWSRoundTrip(t *testing.T) { h := &fakeHandler{} s, pairURL := startTestServer(t, h) base, cred := sessionCred(t, pairURL) conn, _, err := dialWS(t, base, cred) if err != nil { t.Fatalf("dial ws: %v", err) } defer conn.Close(websocket.StatusNormalClosure, "") ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() // First frame should be the connected status. var connected Frame if err := wsjson.Read(ctx, conn, &connected); err != nil { t.Fatalf("read connected: %v", err) } if connected.Type != FrameStatus || connected.State != StatusConnected { t.Fatalf("first frame = %+v, want status/connected", connected) } // Client -> server input reaches the handler. if err := wsjson.Write(ctx, conn, Frame{Type: FrameInput, Text: "hello"}); err != nil { t.Fatalf("write input: %v", err) } deadline := time.Now().Add(time.Second) for h.lastInput() != "hello" { if time.Now().After(deadline) { t.Fatalf("handler never received input, got %q", h.lastInput()) } time.Sleep(5 * time.Millisecond) } // Server -> client output reaches the browser. s.SendOutput("world") var out Frame if err := wsjson.Read(ctx, conn, &out); err != nil { t.Fatalf("read output: %v", err) } if out.Type != FrameOutput || out.Text != "world" { t.Fatalf("output frame = %+v, want output/world", out) } } func TestWSRejectsUnauth(t *testing.T) { _, pairURL := startTestServer(t, nil) base := pairURL[:strings.Index(pairURL, "/pair")] _, resp, err := dialWS(t, base, "not-a-valid-cred") if err == nil { t.Fatal("dial with bad cred succeeded, want failure") } if resp != nil && resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", resp.StatusCode) } } // readOutput reads frames until it sees a FrameOutput and returns its text, // skipping any interleaved status frames. func readOutput(t *testing.T, ctx context.Context, conn *websocket.Conn) string { t.Helper() for { var f Frame if err := wsjson.Read(ctx, conn, &f); err != nil { t.Fatalf("read output: %v", err) } if f.Type == FrameOutput { return f.Text } } } // TestOutputCoalesced verifies that a burst of writes is coalesced into a // single output frame by the pump rather than one frame per write, and that no // bytes are dropped. func TestOutputCoalesced(t *testing.T) { s, pairURL := startTestServer(t, &fakeHandler{}) base, cred := sessionCred(t, pairURL) conn, _, err := dialWS(t, base, cred) if err != nil { t.Fatalf("dial ws: %v", err) } defer conn.Close(websocket.StatusNormalClosure, "") ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // Drain the connected status frame. var connected Frame if err := wsjson.Read(ctx, conn, &connected); err != nil { t.Fatalf("read connected: %v", err) } // Wait for the server to register the client so writes are not lost before // the pump has a live connection. deadline := time.Now().Add(time.Second) for !s.HasClient() { if time.Now().After(deadline) { t.Fatal("client never registered") } time.Sleep(2 * time.Millisecond) } // Emit a burst within one flush interval. const n = 50 want := "" for i := 0; i < n; i++ { s.SendOutput("x") want += "x" } // Read frames until we have accumulated all the bytes. They must arrive in // order and total exactly n bytes (no drops, no duplication). Coalescing // should produce far fewer than n frames. got := "" frames := 0 for len(got) < len(want) { got += readOutput(t, ctx, conn) frames++ } if got != want { t.Fatalf("coalesced output = %q, want %q", got, want) } if frames >= n { t.Fatalf("got %d frames for %d writes, expected coalescing", frames, n) } } // TestReconnectReplay verifies that a client reconnecting mid-session is // replayed the recent scrollback from the ring buffer. func TestReconnectReplay(t *testing.T) { s, pairURL := startTestServer(t, &fakeHandler{}) base, cred := sessionCred(t, pairURL) // First client connects, receives some output, then disconnects. conn1, _, err := dialWS(t, base, cred) if err != nil { t.Fatalf("dial ws 1: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() var connected Frame if err := wsjson.Read(ctx, conn1, &connected); err != nil { t.Fatalf("read connected 1: %v", err) } deadline := time.Now().Add(time.Second) for !s.HasClient() { if time.Now().After(deadline) { t.Fatal("client 1 never registered") } time.Sleep(2 * time.Millisecond) } s.SendOutput("scrollback") if out := readOutput(t, ctx, conn1); out != "scrollback" { t.Fatalf("client 1 output = %q, want scrollback", out) } conn1.Close(websocket.StatusNormalClosure, "") // Wait for the server to release the client slot. deadline = time.Now().Add(time.Second) for s.HasClient() { if time.Now().After(deadline) { t.Fatal("client 1 slot never released") } time.Sleep(2 * time.Millisecond) } // Second client connects and should be replayed the scrollback. conn2, _, err := dialWS(t, base, cred) if err != nil { t.Fatalf("dial ws 2: %v", err) } defer conn2.Close(websocket.StatusNormalClosure, "") if err := wsjson.Read(ctx, conn2, &connected); err != nil { t.Fatalf("read connected 2: %v", err) } if out := readOutput(t, ctx, conn2); out != "scrollback" { t.Fatalf("replay output = %q, want scrollback", out) } } // TestClientConnectDisconnectCallbacks verifies the terminal-notice callbacks // fire on connect and disconnect (§7.3). func TestClientConnectDisconnectCallbacks(t *testing.T) { var mu sync.Mutex var connectedAddr string connected := make(chan struct{}, 1) disconnected := make(chan struct{}, 1) cfg := Config{ Host: "127.0.0.1", Port: 0, OnClientConnect: func(addr string) { mu.Lock() connectedAddr = addr mu.Unlock() connected <- struct{}{} }, OnClientDisconnect: func() { disconnected <- struct{}{} }, } s := NewServer(cfg, &fakeHandler{}) pairURL, err := s.Start() if err != nil { t.Fatalf("Start: %v", err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = s.Stop(ctx) }) base, cred := sessionCred(t, pairURL) conn, _, err := dialWS(t, base, cred) if err != nil { t.Fatalf("dial ws: %v", err) } select { case <-connected: case <-time.After(2 * time.Second): t.Fatal("OnClientConnect never fired") } mu.Lock() addr := connectedAddr mu.Unlock() if addr == "" { t.Fatal("OnClientConnect got empty remote addr") } conn.Close(websocket.StatusNormalClosure, "") select { case <-disconnected: case <-time.After(2 * time.Second): t.Fatal("OnClientDisconnect never fired") } } func TestWSSingleClient(t *testing.T) { s, pairURL := startTestServer(t, &fakeHandler{}) base, cred := sessionCred(t, pairURL) conn1, _, err := dialWS(t, base, cred) if err != nil { t.Fatalf("dial first: %v", err) } defer conn1.Close(websocket.StatusNormalClosure, "") // Wait until the server registers the first client. deadline := time.Now().Add(time.Second) for !s.HasClient() { if time.Now().After(deadline) { t.Fatal("server never registered first client") } time.Sleep(5 * time.Millisecond) } _, resp, err := dialWS(t, base, cred) if err == nil { t.Fatal("second client connected, want rejection") } if resp != nil && resp.StatusCode != http.StatusConflict { t.Fatalf("second-client status = %d, want 409", resp.StatusCode) } }