chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user