f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
272 lines
8.4 KiB
Go
272 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/dbtest"
|
|
"go.kenn.io/agentsview/internal/service"
|
|
)
|
|
|
|
func TestNormalizeMCPHTTPAddr(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
addr string
|
|
allowInsecure bool
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{"empty", "", false, "", true},
|
|
{"bare port", "8085", false, "127.0.0.1:8085", false},
|
|
{"colon port", ":8085", false, "127.0.0.1:8085", false},
|
|
{"explicit loopback v4", "127.0.0.1:8085", false, "127.0.0.1:8085", false},
|
|
{"explicit loopback v6", "[::1]:8085", false, "[::1]:8085", false},
|
|
{"localhost", "localhost:8085", false, "localhost:8085", false},
|
|
{"non-loopback rejected", "192.168.1.5:8085", false, "", true},
|
|
{"all-interfaces rejected", "0.0.0.0:8085", false, "", true},
|
|
{"non-loopback opted in", "192.168.1.5:8085", true, "192.168.1.5:8085", false},
|
|
{"all-interfaces opted in", "0.0.0.0:8085", true, "0.0.0.0:8085", false},
|
|
{"not a port", "notaport", false, "", true},
|
|
// Empty host with a port still binds all interfaces, so it must be
|
|
// rejected without the opt-in.
|
|
{"empty host footgun", "[]:8085", false, "", true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := normalizeMCPHTTPAddr(tc.addr, tc.allowInsecure)
|
|
if tc.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMCPListenerAuth(t *testing.T) {
|
|
t.Parallel()
|
|
// Loopback without require_auth is local-trust: no listener auth, even
|
|
// when a token happens to be configured.
|
|
tok, err := mcpListenerAuth("127.0.0.1:8085", "", false)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, tok)
|
|
tok, err = mcpListenerAuth("[::1]:8085", "abc", false)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, tok, "loopback bind does not enforce a token without require_auth")
|
|
|
|
// require_auth forces auth even on loopback, so a forwarded port is
|
|
// never an unauthenticated surface.
|
|
tok, err = mcpListenerAuth("127.0.0.1:8085", "abc", true)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "abc", tok, "require_auth enforces the token on loopback")
|
|
|
|
// require_auth on loopback without a token is refused.
|
|
_, err = mcpListenerAuth("127.0.0.1:8085", "", true)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "auth token")
|
|
|
|
// Non-loopback with a token enforces it.
|
|
tok, err = mcpListenerAuth("192.168.1.5:8085", "abc", false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "abc", tok)
|
|
|
|
// Non-loopback without a token is refused (no unauthenticated remote surface).
|
|
_, err = mcpListenerAuth("192.168.1.5:8085", "", false)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "auth token")
|
|
}
|
|
|
|
func TestNewMCPCommand_Wiring(t *testing.T) {
|
|
t.Parallel()
|
|
cmd := newMCPCommand()
|
|
assert.Equal(t, "mcp", cmd.Use)
|
|
assert.Equal(t, groupData, cmd.GroupID)
|
|
assert.True(t, cmd.SilenceUsage)
|
|
|
|
for _, name := range []string{
|
|
"http", "http-allow-insecure", "server", "server-token-file", "pg",
|
|
} {
|
|
assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag --%s", name)
|
|
}
|
|
}
|
|
|
|
func TestRootCommand_RegistersMCP(t *testing.T) {
|
|
t.Parallel()
|
|
root := newRootCommand()
|
|
var found bool
|
|
for _, c := range root.Commands() {
|
|
if c.Use == "mcp" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "root command should register the mcp subcommand")
|
|
}
|
|
|
|
func TestResolveMCPServicePGFlagUsesPGReadStore(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
remoteDir := t.TempDir()
|
|
t.Setenv("AGENTSVIEW_PG_URL", "postgres://example.test/agentsview")
|
|
t.Setenv("AGENTSVIEW_PG_SCHEMA", "custom_schema")
|
|
seedSession(t, dataDir, "local-session", "local")
|
|
seedSession(t, remoteDir, "pg-session", "remote")
|
|
|
|
remoteDB := dbtest.OpenTestDBAt(t, filepath.Join(remoteDir, "sessions.db"))
|
|
stub := stubPGReadStore(t, remoteDB)
|
|
forbidStartBackgroundServeForTransport(t,
|
|
"agentsview mcp --pg must use the PG read store, not the daemon")
|
|
|
|
cmd := newMCPCommand()
|
|
cmd.SetArgs([]string{"--pg"})
|
|
require.NoError(t, cmd.ParseFlags([]string{"--pg"}))
|
|
|
|
svc, cleanup, err := resolveMCPService(cmd)
|
|
require.NoError(t, err)
|
|
t.Cleanup(cleanup)
|
|
|
|
res, err := svc.List(context.Background(), service.ListFilter{Limit: 10})
|
|
require.NoError(t, err)
|
|
require.Len(t, res.Sessions, 1)
|
|
assert.Equal(t, "pg-session", res.Sessions[0].ID)
|
|
assert.Equal(t, "postgres://example.test/agentsview", stub.PG.URL)
|
|
assert.Equal(t, "custom_schema", stub.PG.Schema)
|
|
}
|
|
|
|
func TestMCPDaemonServiceStartsDaemonForEachOperation(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
cfg := config.Config{
|
|
DataDir: dataDir,
|
|
DBPath: filepath.Join(dataDir, "sessions.db"),
|
|
}
|
|
var starts int
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/api/v1/sessions", r.URL.Path)
|
|
assert.Equal(t, "7", r.URL.Query().Get("limit"))
|
|
_ = json.NewEncoder(w).Encode(service.SessionList{
|
|
Sessions: []db.Session{{ID: "from-daemon", Agent: "codex"}},
|
|
Total: 1,
|
|
})
|
|
}))
|
|
t.Cleanup(ts.Close)
|
|
host, port := splitTestServerURL(t, ts.URL)
|
|
stubStartBackgroundServeForTransport(t, func(
|
|
context.Context, *config.Config, time.Duration,
|
|
) (*DaemonRuntime, error) {
|
|
starts++
|
|
return &DaemonRuntime{Host: host, Port: port}, nil
|
|
})
|
|
|
|
svc := newMCPDaemonService(cfg)
|
|
for range 2 {
|
|
res, err := svc.List(context.Background(), service.ListFilter{Limit: 7})
|
|
require.NoError(t, err)
|
|
require.Len(t, res.Sessions, 1)
|
|
assert.Equal(t, "from-daemon", res.Sessions[0].ID)
|
|
}
|
|
assert.Equal(t, 2, starts)
|
|
assert.NoFileExists(t, cfg.DBPath)
|
|
}
|
|
|
|
func TestMCPDaemonService_UsagePairwiseComparisonForwardsToDaemon(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
cfg := config.Config{
|
|
DataDir: dataDir,
|
|
DBPath: filepath.Join(dataDir, "sessions.db"),
|
|
}
|
|
|
|
expected := service.UsagePairwiseComparisonResponse{
|
|
Left: service.UsagePairwiseComparisonSide{
|
|
TotalCost: 1.25,
|
|
TotalTokens: 150,
|
|
SessionCount: 2,
|
|
},
|
|
Right: service.UsagePairwiseComparisonSide{
|
|
TotalCost: 3.5,
|
|
TotalTokens: 420,
|
|
SessionCount: 5,
|
|
},
|
|
Deltas: service.UsagePairwiseComparisonDelta{
|
|
TotalCostDelta: 2.25,
|
|
TotalTokensDelta: 270,
|
|
SessionCountDelta: 3,
|
|
},
|
|
}
|
|
|
|
var starts int
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/api/v1/usage/pairwise-comparison", r.URL.Path)
|
|
assert.Equal(t, "2024-06-01", r.URL.Query().Get("from"))
|
|
assert.Equal(t, "2024-06-07", r.URL.Query().Get("to"))
|
|
assert.Equal(t, "UTC", r.URL.Query().Get("timezone"))
|
|
assert.Equal(t, "gpt-4o", r.URL.Query().Get("model"))
|
|
assert.Equal(t, "model", r.URL.Query().Get("left_dimension"))
|
|
assert.Equal(t, "claude-sonnet-4-20250514", r.URL.Query().Get("left_value"))
|
|
assert.Equal(t, "project", r.URL.Query().Get("right_dimension"))
|
|
assert.Equal(t, "proj-b", r.URL.Query().Get("right_value"))
|
|
assert.Equal(t, "3", r.URL.Query().Get("min_user_messages"))
|
|
assert.Equal(t, "true", r.URL.Query().Get("include_one_shot"))
|
|
assert.Equal(t, "false", r.URL.Query().Get("include_automated"))
|
|
_ = json.NewEncoder(w).Encode(expected)
|
|
}))
|
|
t.Cleanup(ts.Close)
|
|
|
|
host, port := splitTestServerURL(t, ts.URL)
|
|
stubStartBackgroundServeForTransport(t, func(
|
|
context.Context, *config.Config, time.Duration,
|
|
) (*DaemonRuntime, error) {
|
|
starts++
|
|
return &DaemonRuntime{Host: host, Port: port}, nil
|
|
})
|
|
|
|
svc := newMCPDaemonService(cfg)
|
|
res, err := svc.UsagePairwiseComparison(
|
|
context.Background(),
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-07",
|
|
Timezone: "UTC",
|
|
MinUserMessages: 3,
|
|
IncludeOneShot: true,
|
|
Model: "gpt-4o",
|
|
},
|
|
LeftDimension: "model",
|
|
LeftValue: "claude-sonnet-4-20250514",
|
|
RightDimension: "project",
|
|
RightValue: "proj-b",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
assert.Equal(t, expected, *res)
|
|
assert.Equal(t, 1, starts)
|
|
assert.NoFileExists(t, cfg.DBPath)
|
|
}
|
|
|
|
func splitTestServerURL(t *testing.T, raw string) (string, int) {
|
|
t.Helper()
|
|
req, err := http.NewRequest(http.MethodGet, raw, nil)
|
|
require.NoError(t, err)
|
|
host, portText, err := net.SplitHostPort(req.URL.Host)
|
|
require.NoError(t, err)
|
|
var port int
|
|
_, err = fmt.Sscanf(portText, "%d", &port)
|
|
require.NoError(t, err)
|
|
return host, port
|
|
}
|