Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

229 lines
7.6 KiB
Go

package daemon
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// newDaemonNoServe is newDaemon's twin that stops short of calling
// Listen / Serve so the test can mutate fields like HTTPAddr /
// HTTPHandler before the listener comes up.
func newDaemonNoServe(t *testing.T, ctrl Controller) (*Server, string) {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(dir) })
socket := filepath.Join(dir, "s")
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
srv := New(socket, "test-0.0.0", zap.NewNop())
srv.Controller = ctrl
t.Cleanup(func() { _ = srv.Shutdown() })
return srv, socket
}
// TestDaemon_HTTPListenerServesAttachedHandler proves the daemon
// Server brings up an HTTP listener on HTTPAddr when HTTPHandler is
// set, that the handler reaches inbound requests, and that Shutdown
// tears the listener down cleanly. The streamable transport itself
// is exercised in internal/mcp/streamable; this test only proves the
// daemon-side plumbing.
func TestDaemon_HTTPListenerServesAttachedHandler(t *testing.T) {
ctrl := &fakeController{}
srv, _ := newDaemonNoServe(t, ctrl)
srv.HTTPHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("X-Method", r.Method)
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok")
})
srv.HTTPAddr = "127.0.0.1:0"
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
require.Eventually(t, func() bool {
return srv.httpListener != nil
}, 2*time.Second, 10*time.Millisecond, "http listener never came up")
addr := srv.httpListener.Addr().String()
resp, err := http.Get("http://" + addr + "/anything")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "GET", resp.Header.Get("X-Method"))
body, _ := io.ReadAll(resp.Body)
require.Equal(t, "ok", string(body))
require.NoError(t, srv.Shutdown())
require.Eventually(t, func() bool {
_, err := http.Get("http://" + addr + "/anything")
return err != nil
}, 2*time.Second, 10*time.Millisecond, "http listener stayed up after Shutdown")
}
// TestSessionRegistry_RegisterDetached covers the detached-session
// helper the streamable HTTP path relies on.
func TestSessionRegistry_RegisterDetached(t *testing.T) {
r := NewSessionRegistry()
sess := r.RegisterDetached("custom-id-42", Handshake{
Mode: ModeMCP,
CWD: "/tmp/x",
ClientName: "http",
})
require.NotNil(t, sess)
require.Equal(t, "custom-id-42", sess.ID)
require.Equal(t, "http", sess.ClientName)
require.Equal(t, "/tmp/x", sess.CWD)
require.Nil(t, sess.Conn, "detached session must not carry a Conn")
got := r.GetByID("custom-id-42")
require.NotNil(t, got)
require.Equal(t, sess, got)
removed := r.RemoveByID("custom-id-42")
require.NotNil(t, removed)
require.Nil(t, r.GetByID("custom-id-42"))
require.Nil(t, r.RemoveByID("custom-id-42"))
require.Nil(t, r.RemoveByID(""))
}
func TestSessionRegistry_RegisterDetachedAutoGenID(t *testing.T) {
r := NewSessionRegistry()
a := r.RegisterDetached("", Handshake{Mode: ModeMCP})
b := r.RegisterDetached("", Handshake{Mode: ModeMCP})
require.NotEmpty(t, a.ID)
require.NotEmpty(t, b.ID)
require.NotEqual(t, a.ID, b.ID)
require.Equal(t, 2, r.Count())
}
// TestDaemon_HTTPAndUnixCoexist confirms the unix-socket transport
// still works when an HTTP listener is also attached.
func TestDaemon_HTTPAndUnixCoexist(t *testing.T) {
ctrl := &fakeController{}
srv, socket := newDaemonNoServe(t, ctrl)
srv.HTTPHandler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
srv.HTTPAddr = "127.0.0.1:0"
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
require.Eventually(t, func() bool {
return srv.httpListener != nil && IsRunningAt(socket)
}, 2*time.Second, 10*time.Millisecond)
httpAddr := srv.httpListener.Addr().String()
resp, err := http.Get("http://" + httpAddr)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusTeapot, resp.StatusCode)
c, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
require.NoError(t, c.Close())
}
// TestDaemon_HTTPListenerFailureAbortsListen covers the operator
// misconfig case: pointing at a port that's already in use surfaces
// as a fatal Listen error rather than a silent half-up daemon.
func TestDaemon_HTTPListenerFailureAbortsListen(t *testing.T) {
occupied, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = occupied.Close() })
ctrl := &fakeController{}
srv, _ := newDaemonNoServe(t, ctrl)
srv.HTTPHandler = http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
srv.HTTPAddr = occupied.Addr().String()
err = srv.Listen()
require.Error(t, err, "Listen should fail when http addr is in use")
require.Contains(t, err.Error(), "listen http")
}
// TestDaemon_NoHTTPAddrKeepsHTTPListenerNil — default-off path: no
// HTTPAddr means no port opens.
func TestDaemon_NoHTTPAddrKeepsHTTPListenerNil(t *testing.T) {
ctrl := &fakeController{}
srv, _ := newDaemonNoServe(t, ctrl)
require.NoError(t, srv.Listen())
require.Nil(t, srv.httpListener)
require.NoError(t, srv.Shutdown())
}
// TestDaemon_StreamableHTTPEndToEnd wires a streamable-shaped
// handler onto the daemon's HTTP listener and confirms a JSON-RPC
// roundtrip works over the wire. The real streamable.Transport lives
// in internal/mcp/streamable with its own dedicated tests; this one
// proves the daemon's HTTPHandler / HTTPAddr / SessionRegistry
// integration in one place.
func TestDaemon_StreamableHTTPEndToEnd(t *testing.T) {
ctrl := &fakeController{}
srv, _ := newDaemonNoServe(t, ctrl)
srv.MCPDispatcher = httpEchoDispatcher{}
srv.HTTPHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, _ := io.ReadAll(r.Body)
sess := srv.sessions.RegisterDetached("", Handshake{Mode: ModeMCP})
w.Header().Set("Mcp-Session-Id", sess.ID)
w.Header().Set("Content-Type", "application/json")
reply, _ := srv.MCPDispatcher.Dispatch(r.Context(), sess, body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(reply)
})
srv.HTTPAddr = "127.0.0.1:0"
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
require.Eventually(t, func() bool { return srv.httpListener != nil },
2*time.Second, 10*time.Millisecond)
addr := srv.httpListener.Addr().String()
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"echo"}`)
resp, err := http.Post("http://"+addr, "application/json", bytes.NewReader(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
sid := resp.Header.Get("Mcp-Session-Id")
require.NotEmpty(t, sid)
out, _ := io.ReadAll(resp.Body)
require.Contains(t, string(out), `"jsonrpc":"2.0"`)
require.NotNil(t, srv.sessions.GetByID(sid))
}
// httpEchoDispatcher is the minimal MCPDispatcher used by
// TestDaemon_StreamableHTTPEndToEnd — returns a canned envelope
// naming the session id so the test can prove the dispatch path
// threaded both inputs through.
type httpEchoDispatcher struct{}
func (httpEchoDispatcher) Dispatch(_ context.Context, sess *Session, _ []byte) ([]byte, error) {
out, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"result": map[string]any{
"session_id": sess.ID,
"echoed": "yes",
},
})
return out, nil
}