diff --git a/internal/gateway/main_test.go b/internal/gateway/main_test.go index fc39234f..e88a938f 100644 --- a/internal/gateway/main_test.go +++ b/internal/gateway/main_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/yetone/magpie/internal/agentenv" + "github.com/yetone/magpie/internal/testenv" ) func TestMain(m *testing.M) { @@ -62,5 +63,9 @@ func isolatedTests(m *testing.M) (int, error) { // no route is written to disk behind a test's back; the history's own // tests call saveRoute themselves keepRoutes = false + // no test asks a vendor: a provider with a real base URL and a made-up + // key would, in the background (a plan key's windows, #1016), and a + // plugin host's first start fetched models.dev + testenv.Offline() return m.Run(), nil } diff --git a/internal/provider/kiro_main_test.go b/internal/provider/kiro_main_test.go index da0c9dfd..2562782d 100644 --- a/internal/provider/kiro_main_test.go +++ b/internal/provider/kiro_main_test.go @@ -12,9 +12,12 @@ import ( // them: Accounts() reads them, and asks Kiro who the account is. It also // gives the package a home of its own (testenv), so what a test leaves // behind it — a CLI's answer kept by refresh after the test is over — -// never lands in the runner's magpie, nor in APPDATA on Windows. +// never lands in the runner's magpie, nor in APPDATA on Windows. And it +// keeps them off the network (testenv.Offline): a test of a sign-in or a +// key at a vendor's real address asks only what it serves on loopback. func TestMain(m *testing.M) { os.Exit(testenv.RunIn(m, func(home string) { + testenv.Offline() kiroCLIDB = func() string { return filepath.Join(home, "kiro-test", "kiro-cli", "data.sqlite3") } kiroIDEDir = func() string { return filepath.Join(home, "kiro-test", "sso") } askKiroIdentity = func(string, string) (string, string) { return "", "" } diff --git a/internal/testenv/offline.go b/internal/testenv/offline.go new file mode 100644 index 00000000..f5811cf6 --- /dev/null +++ b/internal/testenv/offline.go @@ -0,0 +1,62 @@ +package testenv + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "strings" + "sync" +) + +// Offline keeps the package's tests off the network: http.DefaultTransport +// (and so http.DefaultClient, and every transport cloned from it after the +// call) dials this computer only — localhost, a name under .localhost, a +// loopback or unspecified address — and refuses anything else before it is +// looked up, saying so on stderr once per address. A test that wants a +// vendor serves it on loopback and points the request there (an +// httptest.Server, a Transport rewriting the host). Without it the tests +// of internal/gateway and internal/provider asked the real models.dev (its +// catalog, fetched as each plugin host started), api.github.com, +// chatgpt.com and Devin's server.codeium.com with made-up accounts, and a +// plan key with a vendor's real base URL would have its windows read for +// routing (#1016). A package calls it from its TestMain, before m.Run. +func Offline() { + t, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return + } + dial := t.DialContext + if dial == nil { + dial = (&net.Dialer{}).DialContext + } + var mu sync.Mutex + said := map[string]bool{} + t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + if local(addr) { + return dial(ctx, network, addr) + } + mu.Lock() + if !said[addr] { + said[addr] = true + fmt.Fprintf(os.Stderr, "testenv: a test asked %s, refused: tests stay off the network\n", addr) + } + mu.Unlock() + return nil, fmt.Errorf("testenv: %s is not this computer; tests stay off the network", addr) + } +} + +// local says addr, a host:port, is this computer, without looking it up. +func local(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + host = strings.ToLower(strings.TrimSuffix(host, ".")) + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return true + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && (ip.IsLoopback() || ip.IsUnspecified()) +} diff --git a/internal/testenv/offline_test.go b/internal/testenv/offline_test.go new file mode 100644 index 00000000..86cc103e --- /dev/null +++ b/internal/testenv/offline_test.go @@ -0,0 +1,44 @@ +package testenv + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestLocal(t *testing.T) { + for addr, want := range map[string]bool{ + "127.0.0.1:80": true, "[::1]:443": true, "localhost:3425": true, "LOCALHOST.:1": true, + "magpie.localhost:80": true, "0.0.0.0:80": true, "[::]:80": true, "127.8.0.1:9": true, + "models.dev:443": false, "api.kimi.com:443": false, "10.0.0.132:22": false, + "192.168.1.2:80": false, "localhost.example.com:80": false, "decide.test:443": false, + } { + if got := local(addr); got != want { + t.Errorf("local(%q) = %v, want %v", addr, got, want) + } + } +} + +// Offline lets a loopback server answer through http.DefaultClient and +// refuses a vendor's host without dialing it. +func TestOffline(t *testing.T) { + tr := http.DefaultTransport.(*http.Transport) + was := tr.DialContext + t.Cleanup(func() { tr.DialContext = was }) + Offline() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("here")) })) + defer srv.Close() + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("loopback refused: %v", err) + } + resp.Body.Close() + clone := tr.Clone() // a transport made from it after the call + for _, c := range []*http.Client{http.DefaultClient, {Transport: clone}} { + _, err := c.Get("https://models.dev/api.json") + if err == nil || !strings.Contains(err.Error(), "tests stay off the network") { + t.Errorf("models.dev: %v", err) + } + } +}