From a8d798e2cb8e42dee83c79a57eb4fe3fb4d69916 Mon Sep 17 00:00:00 2001 From: yetone Date: Tue, 6 Oct 2026 21:08:49 +0800 Subject: [PATCH] =?UTF-8?q?gateway,=20provider=20tests:=20the=20tests=20st?= =?UTF-8?q?ay=20off=20the=20network=20(testenv.Offline=20in=20their=20Test?= =?UTF-8?q?Mains)=20=E2=80=94=20after=20555f5706=20(#1016)=20routing=20rea?= =?UTF-8?q?ds=20a=20plan=20key's=20quota=20windows,=20and=20a=20test=20who?= =?UTF-8?q?se=20provider=20has=20a=20vendor's=20real=20base=20URL=20and=20?= =?UTF-8?q?a=20made-up=20key=20would=20ask=20the=20vendor=20in=20the=20bac?= =?UTF-8?q?kground.=20Routing's=20own=20read=20(keyAllowance,=20allowances?= =?UTF-8?q?)=20was=20already=20stubbed=20in=20routing=5Ftest.go's=20init,?= =?UTF-8?q?=20and=20a=20survey=20with=20a=20dial=20guard=20found=20no=20pl?= =?UTF-8?q?an-key=20dial,=20but=20it=20found=20others:=20every=20plugin=20?= =?UTF-8?q?host's=20first=20start=20fetched=20models.dev's=20catalog=20(65?= =?UTF-8?q?=20dials=20in=20the=20gateway's=20plugin=20tests),=20and=20prov?= =?UTF-8?q?ider=20tests=20asked=20api.github.com=20(TestSubscriptionUsageE?= =?UTF-8?q?achAccount,=20TestSubscriptionUsageServesStale),=20chatgpt.com?= =?UTF-8?q?=20(TestCopilotQuotaWithoutEditorsSignIn),=20server.codeium.com?= =?UTF-8?q?=20(TestDevinSignedInOnlyInMagpie)=20and=20decide.test=20(TestM?= =?UTF-8?q?odelTest)=20with=20made-up=20accounts;=20each=20passed=20whethe?= =?UTF-8?q?r=20the=20vendor=20answered=20or=20not.=20internal/testenv's=20?= =?UTF-8?q?Offline=20wraps=20http.DefaultTransport's=20DialContext=20(so?= =?UTF-8?q?=20http.DefaultClient=20and=20every=20transport=20cloned=20from?= =?UTF-8?q?=20it=20later):=20localhost,=20*.localhost,=20loopback=20and=20?= =?UTF-8?q?unspecified=20addresses=20are=20dialled,=20anything=20else=20is?= =?UTF-8?q?=20refused=20before=20it=20is=20looked=20up,=20said=20once=20pe?= =?UTF-8?q?r=20address=20on=20stderr=20("testenv:=20a=20test=20asked=20mod?= =?UTF-8?q?els.dev:443,=20refused:=20tests=20stay=20off=20the=20network").?= =?UTF-8?q?=20internal/gateway's=20TestMain=20and=20internal/provider's=20?= =?UTF-8?q?(kiro=5Fmain=5Ftest.go,=20in=20RunIn's=20setup)=20call=20it=20b?= =?UTF-8?q?efore=20the=20tests=20run.=20TestLocal=20(the=20address=20table?= =?UTF-8?q?)=20and=20TestOffline=20(a=20loopback=20httptest=20server=20ans?= =?UTF-8?q?wers=20through=20DefaultClient;=20models.dev=20is=20refused=20t?= =?UTF-8?q?hrough=20it=20and=20through=20a=20Clone=20made=20after=20the=20?= =?UTF-8?q?call)=20pass=20-race=20-count=3D3.=20go=20test=20-race=20-tags?= =?UTF-8?q?=20nogui=20-timeout=2030m=20./internal/testenv=20./internal/pro?= =?UTF-8?q?vider=20./internal/gateway=20passes=20under=20a=20temp=20HOME?= =?UTF-8?q?=20(gateway=20516=20s).=20The=20gateway=20and=20provider=20test?= =?UTF-8?q?=20binaries=20were=20then=20run=20under=20sandbox-exec=20with?= =?UTF-8?q?=20every=20non-loopback=20network-outbound=20denied,=20with=20t?= =?UTF-8?q?he=20kernel's=20Sandbox=20reports=20streamed:=20both=20PASS,=20?= =?UTF-8?q?Offline=20refused=20only=20the=20six=20addresses=20above,=20and?= =?UTF-8?q?=20the=20kernel=20reported=20no=20denial,=20so=20no=20transport?= =?UTF-8?q?=20outside=20DefaultTransport=20reaches=20the=20network=20eithe?= =?UTF-8?q?r.=20go=20vet,=20gofmt=20-l,=20linux=20and=20windows=20(nogui)?= =?UTF-8?q?=20builds=20pass.=20Tests=20only.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/main_test.go | 5 +++ internal/provider/kiro_main_test.go | 5 ++- internal/testenv/offline.go | 62 +++++++++++++++++++++++++++++ internal/testenv/offline_test.go | 44 ++++++++++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 internal/testenv/offline.go create mode 100644 internal/testenv/offline_test.go 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) + } + } +}