From 763f8fea0152b762fa05c9bf0fe8f66812f14359 Mon Sep 17 00:00:00 2001 From: Zhimao Liu Date: Fri, 12 Jun 2026 04:14:44 -0700 Subject: [PATCH] [FIX] sync surf API spec from configured base (#27) * [FIX] sync surf API spec from configured base * fix(tests): build surf binary with windows extension * fix(cli): keep surf cache tests hermetic --------- Co-authored-by: HappySean --- cli/api.go | 34 +++++- cli/apiconfig.go | 43 ++++++- cmd/surf/main.go | 132 ++++++++++++++++++-- cmd/surf/main_test.go | 19 +++ cmd/surf/network_error_test.go | 101 ++++++++++++---- cmd/surf/usage_test.go | 213 ++++++++++++++++++++++++++++++--- 6 files changed, 489 insertions(+), 53 deletions(-) diff --git a/cli/api.go b/cli/api.go index 22cd324..bd082bc 100644 --- a/cli/api.go +++ b/cli/api.go @@ -103,6 +103,9 @@ func cacheAPI(name string, api *API) { return } + if base := apiCacheBase(name); base != "" { + Cache.Set(name+".base", base) + } Cache.Set(name+".expires", time.Now().Add(24*time.Hour)) Cache.WriteConfig() @@ -116,11 +119,35 @@ func cacheAPI(name string, api *API) { } } +func apiCacheBase(name string) string { + config := configs[name] + if config == nil { + return "" + } + base := config.Base + profile := viper.GetString("rsh-profile") + if profile != "" && profile != "default" && config.Profiles[profile] != nil && config.Profiles[profile].Base != "" { + base = config.Profiles[profile].Base + } + return strings.TrimRight(base, "/") +} + +func apiCacheMatchesBase(name string) bool { + expected := apiCacheBase(name) + if expected == "" { + return true + } + return Cache.GetString(name+".base") == expected +} + // LoadCachedAPI loads an API from the local cache without making network // requests. Returns nil if no valid cache exists or is expired. // Unlike Load, this skips the version check since it is used only to // populate command names and descriptions for help output. func LoadCachedAPI(name string) *API { + if !apiCacheMatchesBase(name) { + return nil + } expires := Cache.GetTime(name + ".expires") if expires.IsZero() || !time.Now().Before(expires) { return nil @@ -161,12 +188,15 @@ func Load(entrypoint string, root *cobra.Command) (API, error) { // See if there is a cache we can quickly load. expires := Cache.GetTime(name + ".expires") - if !viper.GetBool("rsh-no-cache") && !expires.IsZero() && expires.After(time.Now()) { + if !viper.GetBool("rsh-no-cache") && apiCacheMatchesBase(name) && !expires.IsZero() && expires.After(time.Now()) { var cached API filename := filepath.Join(getCacheDir(), name+".cbor") if data, err := os.ReadFile(filename); err == nil { if err := cbor.Unmarshal(data, &cached); err == nil { - if cached.RestishVersion == root.Version { + // API subcommands do not carry the root CLI version, but they still + // need to reuse the already-validated cache instead of refreshing the + // spec on every operation invocation. + if root.Version == "" || cached.RestishVersion == root.Version { setupRootFromAPI(root, &cached) return cached, nil } diff --git a/cli/apiconfig.go b/cli/apiconfig.go index 97d783e..0dbdd84 100644 --- a/cli/apiconfig.go +++ b/cli/apiconfig.go @@ -27,10 +27,10 @@ type APIAuth struct { // TLSConfig contains the TLS setup for the HTTP client type TLSConfig struct { - InsecureSkipVerify bool `json:"insecure,omitempty" yaml:"insecure,omitempty" mapstructure:"insecure"` - Cert string `json:"cert,omitempty" yaml:"cert,omitempty"` - Key string `json:"key,omitempty" yaml:"key,omitempty"` - CACert string `json:"ca_cert,omitempty" yaml:"ca_cert,omitempty" mapstructure:"ca_cert"` + InsecureSkipVerify bool `json:"insecure,omitempty" yaml:"insecure,omitempty" mapstructure:"insecure"` + Cert string `json:"cert,omitempty" yaml:"cert,omitempty"` + Key string `json:"key,omitempty" yaml:"key,omitempty"` + CACert string `json:"ca_cert,omitempty" yaml:"ca_cert,omitempty" mapstructure:"ca_cert"` } // APIProfile contains account-specific API information @@ -87,6 +87,41 @@ type apiConfigs map[string]*APIConfig var configs apiConfigs var apiCommand *cobra.Command +// OverrideAPIConfig updates an API config in memory. This is used for Surf's +// environment override before commands are registered from the OpenAPI cache. +func OverrideAPIConfig(name, base string, specFiles []string) { + config := configs[name] + if config == nil { + config = &APIConfig{name: name} + } + config.name = name + config.Base = base + config.SpecFiles = specFiles + if config.Profiles == nil { + config.Profiles = map[string]*APIProfile{} + } + if config.Profiles["default"] == nil { + config.Profiles["default"] = &APIProfile{} + } + configs[name] = config + + for _, cmd := range Root.Commands() { + if cmd.Use == name { + cmd.Short = base + break + } + } +} + +// APIBase returns the configured base URL for an API, if present. +func APIBase(name string) string { + config := configs[name] + if config == nil { + return "" + } + return config.Base +} + func initAPIConfig() { apis = viper.New() diff --git a/cmd/surf/main.go b/cmd/surf/main.go index 5845e54..cf73942 100644 --- a/cmd/surf/main.go +++ b/cmd/surf/main.go @@ -22,6 +22,8 @@ var embeddedAPIsJSON []byte var version = "dev" var configDir string +const defaultSurfGatewayBase = "https://api.asksurf.ai/gateway" + func main() { // Force config and cache to ~/.surf/ on all platforms. home, err := os.UserHomeDir() @@ -59,6 +61,7 @@ func main() { // Initialize restish. cli.Init("surf", version) cli.Defaults() + applySurfAPIBaseURLOverride() cli.AddLoader(openapi.New()) cli.AddOperationCommandHook(addHiddenCompatOperationFlags) @@ -202,7 +205,7 @@ func main() { if cli.LoadCachedAPI("surf") == nil && needsCachedAPI() { fmt.Fprintln(os.Stderr, "No cached API spec, syncing...") viper.Set("rsh-no-cache", true) - if _, err := cli.Load("https://api.asksurf.ai/gateway", cli.Root); err != nil { + if _, err := cli.Load(currentSurfGatewayBase(), cli.Root); err != nil { fmt.Fprintf(os.Stderr, "Auto-sync failed: %v\n", err) // Fall through — the downstream "no cached API spec" / // "unknown command" path will produce a more specific error. @@ -307,11 +310,8 @@ func needsCachedAPI() bool { "help": true, "completion": true, "version": true, "install": true, "telemetry": true, "feedback": true, } - for _, arg := range os.Args[1:] { - if strings.HasPrefix(arg, "-") { - continue - } - return !meta[arg] + if cmd := firstCommandArg(os.Args); cmd != "" { + return !meta[cmd] } // No command given → will show root help, no sync needed. return false @@ -335,14 +335,121 @@ func shouldInjectAPIName() bool { return false } } - for _, arg := range os.Args[1:] { + if cmd := firstCommandArg(os.Args); cmd != "" { + return !local[cmd] + } + // No non-flag args → show help, no injection. + return false +} + +func firstCommandArg(argv []string) string { + for i := 1; i < len(argv); i++ { + arg := argv[i] + if arg == "--" { + if i+1 < len(argv) { + return argv[i+1] + } + return "" + } + if strings.HasPrefix(arg, "--") { + if flagConsumesValue(arg) && !strings.Contains(arg, "=") { + i++ + } + continue + } if strings.HasPrefix(arg, "-") { + if shortFlagConsumesValue(arg) && len(arg) == 2 { + i++ + } continue } - return !local[arg] + return arg } - // No non-flag args → show help, no injection. - return false + return "" +} + +func flagConsumesValue(arg string) bool { + name := strings.TrimPrefix(arg, "--") + if idx := strings.Index(name, "="); idx >= 0 { + name = name[:idx] + } + switch name { + case "surf-api-base-url", "rsh-output-format", "rsh-filter", "rsh-header", + "rsh-query", "rsh-profile", "rsh-client-cert", "rsh-client-key", + "rsh-ca-cert", "rsh-retry", "rsh-timeout", "category": + return true + default: + return false + } +} + +func shortFlagConsumesValue(arg string) bool { + name := strings.TrimPrefix(arg, "-") + if idx := strings.Index(name, "="); idx >= 0 { + name = name[:idx] + } + switch name { + case "s", "o", "f", "H", "q", "p", "t", "c": + return true + default: + return false + } +} + +func explicitSurfAPIBaseURLOverride(args []string) string { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case strings.HasPrefix(arg, "--surf-api-base-url="): + return strings.TrimPrefix(arg, "--surf-api-base-url=") + case arg == "--surf-api-base-url" && i+1 < len(args): + return args[i+1] + case strings.HasPrefix(arg, "-s="): + return strings.TrimPrefix(arg, "-s=") + case arg == "-s" && i+1 < len(args): + return args[i+1] + case strings.HasPrefix(arg, "-s") && len(arg) > 2: + return strings.TrimPrefix(arg, "-s") + } + } + return os.Getenv("SURF_API_BASE_URL") +} + +func normalizeSurfGatewayBase(raw string) string { + base := strings.TrimRight(strings.TrimSpace(raw), "/") + if strings.HasSuffix(base, "/v1") { + base = strings.TrimSuffix(base, "/v1") + } + return base +} + +func surfSpecFile(base string) string { + return strings.TrimRight(base, "/") + "/openapi.json" +} + +func applySurfAPIBaseURLOverride() { + raw := explicitSurfAPIBaseURLOverride(os.Args[1:]) + if raw == "" { + return + } + base := normalizeSurfGatewayBase(raw) + if base == "" { + return + } + viper.Set("surf-api-base-url", raw) + cli.OverrideAPIConfig("surf", base, []string{surfSpecFile(base)}) +} + +func currentSurfGatewayBase() string { + if raw := explicitSurfAPIBaseURLOverride(os.Args[1:]); raw != "" { + if base := normalizeSurfGatewayBase(raw); base != "" { + return base + } + } + if base := cli.APIBase("surf"); base != "" { + return strings.TrimRight(base, "/") + } + return defaultSurfGatewayBase } func removeCommands(root *cobra.Command, names ...string) { @@ -451,9 +558,12 @@ func newSyncCmd() *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { viper.Set("rsh-no-cache", true) - if _, err := cli.Load("https://api.asksurf.ai/gateway", cli.Root); err != nil { + if _, err := cli.Load(currentSurfGatewayBase(), cli.Root); err != nil { return fmt.Errorf("sync failed: %w", err) } + if cli.LoadCachedAPI("surf") == nil { + return fmt.Errorf("sync failed: cached API spec was not written") + } fmt.Fprintln(os.Stderr, "API spec synced.") return nil }, diff --git a/cmd/surf/main_test.go b/cmd/surf/main_test.go index f0d4019..83faced 100644 --- a/cmd/surf/main_test.go +++ b/cmd/surf/main_test.go @@ -55,6 +55,21 @@ func TestShouldInjectAPIName(t *testing.T) { args: []string{"surf", "-o", "json", "wallet-labels-batch"}, want: true, }, + { + name: "leading surf API base URL before sync does not inject", + args: []string{"surf", "--surf-api-base-url", "https://api.stg.ask.surf/gateway/v1", "sync"}, + want: false, + }, + { + name: "leading surf API base URL before operation still injects", + args: []string{"surf", "--surf-api-base-url", "https://api.stg.ask.surf/gateway/v1", "wallet-labels-batch"}, + want: true, + }, + { + name: "leading surf API base URL shorthand before operation still injects", + args: []string{"surf", "-s", "https://api.stg.ask.surf/gateway/v1", "wallet-labels-batch"}, + want: true, + }, { name: "leading flags before operation with --help do NOT inject", args: []string{"surf", "-o", "json", "wallet-labels-batch", "--help"}, @@ -95,6 +110,10 @@ func TestNeedsCachedAPI(t *testing.T) { {"API command with --help needs cache", []string{"surf", "polymarket-markets", "--help"}, true}, {"leading flags skipped when finding command", []string{"surf", "--debug", "polymarket-markets"}, true}, {"leading flags skipped before meta command", []string{"surf", "--debug", "auth"}, false}, + {"leading surf API base URL skipped before sync", []string{"surf", "--surf-api-base-url", "https://api.stg.ask.surf/gateway/v1", "sync"}, false}, + {"leading surf API base URL skipped before API command", []string{"surf", "--surf-api-base-url", "https://api.stg.ask.surf/gateway/v1", "polymarket-markets"}, true}, + {"leading surf API base URL equals form skipped before list", []string{"surf", "--surf-api-base-url=https://api.stg.ask.surf/gateway/v1", "list-operations"}, true}, + {"leading surf API base URL shorthand skipped before sync", []string{"surf", "-s", "https://api.stg.ask.surf/gateway/v1", "sync"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/surf/network_error_test.go b/cmd/surf/network_error_test.go index b9da76e..7f6893e 100644 --- a/cmd/surf/network_error_test.go +++ b/cmd/surf/network_error_test.go @@ -2,9 +2,14 @@ package main import ( "encoding/json" + "fmt" + "net/http" + "os" "os/exec" + "path/filepath" "strings" "testing" + "time" ) // TestNetworkErrorEnvelope verifies that transport-layer failures @@ -14,7 +19,7 @@ import ( // See docs/CLI_DESIGN_PRINCIPLES.md §4.1 and §4.2.1, and // docs/CLI_V1_ALPHA_26_REVIEW.md items #3 and #4 for the rationale. func TestNetworkErrorEnvelope(t *testing.T) { - bin := buildSurfBin(t) + bin := buildBareSurfBin(t) type envelope struct { Error struct { @@ -24,18 +29,22 @@ func TestNetworkErrorEnvelope(t *testing.T) { } t.Run("connection refused", func(t *testing.T) { - cmd := exec.Command(bin, + home := t.TempDir() + srv, _ := newSpecAndOperationServerWithOpenAPIServer(t, "marketPrice", "http://127.0.0.1:1", nil) + defer srv.Close() + seedSurfSpecCache(t, bin, home, "http://127.0.0.1:1/gateway", srv.URL+"/gateway/openapi.json") + + cmd := surfCmd(t, bin, home, nil, "market-price", - "--symbol", "BTC", - "--time-range", "1d", - "--surf-api-base-url", "http://127.0.0.1:1", + "--address", "BTC", + "--surf-api-base-url", "http://127.0.0.1:1/gateway/v1", "--rsh-retry", "0", ) // Capture stdout and stderr separately. stdout, stderrPipe := splitOutput(cmd) err := cmd.Run() - wantExitCode(t, err, 4) + wantExitCodeWithStderr(t, err, 4, stderrPipe.String()) // stdout must contain a JSON envelope with error.code = NETWORK_ERROR. var env envelope @@ -56,32 +65,44 @@ func TestNetworkErrorEnvelope(t *testing.T) { }) t.Run("dns failure", func(t *testing.T) { - cmd := exec.Command(bin, + home := t.TempDir() + srv, _ := newSpecAndOperationServerWithOpenAPIServer(t, "marketPrice", "http://nonexistent-host-for-test-12345.invalid", nil) + defer srv.Close() + seedSurfSpecCache(t, bin, home, "http://nonexistent-host-for-test-12345.invalid/gateway", srv.URL+"/gateway/openapi.json") + + cmd := surfCmd(t, bin, home, nil, "market-price", - "--symbol", "BTC", - "--time-range", "1d", - "--surf-api-base-url", "http://nonexistent-host-for-test-12345.invalid", + "--address", "BTC", + "--surf-api-base-url", "http://nonexistent-host-for-test-12345.invalid/gateway/v1", "--rsh-retry", "0", ) - stdout, _ := splitOutput(cmd) + stdout, stderrPipe := splitOutput(cmd) err := cmd.Run() - wantExitCode(t, err, 4) + wantExitCodeWithStderr(t, err, 4, stderrPipe.String()) var env envelope if jerr := json.Unmarshal(stdout.Bytes(), &env); jerr != nil { t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", jerr, stdout.String()) } if env.Error.Code != "NETWORK_ERROR" { - t.Errorf("want error.code = NETWORK_ERROR, got %q", env.Error.Code) + t.Errorf("want error.code = NETWORK_ERROR, got %q (stdout: %s)", env.Error.Code, stdout.String()) } }) t.Run("local timeout", func(t *testing.T) { - cmd := exec.Command(bin, + home := t.TempDir() + srv, _ := newSpecAndOperationServerWithOpenAPIServer(t, "marketPrice", "", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + seedSurfSpecCache(t, bin, home, srv.URL+"/gateway", srv.URL+"/gateway/openapi.json") + + cmd := surfCmd(t, bin, home, nil, "market-price", - "--symbol", "BTC", - "--time-range", "1d", + "--address", "BTC", + "--surf-api-base-url", srv.URL+"/gateway/v1", "--rsh-timeout", "1ms", ) stdout, _ := splitOutput(cmd) @@ -104,12 +125,47 @@ func TestNetworkErrorEnvelope(t *testing.T) { // Sanity check: unknown flag errors still exit 1 with stderr text // (they are CLI-layer errors, not transport errors). t.Run("unknown flag still exit 1", func(t *testing.T) { - cmd := exec.Command(bin, "market-price", "--nonexistent-flag") + home := t.TempDir() + srv, _ := newSpecServer(t, "marketPrice") + defer srv.Close() + seedSurfSpecCache(t, bin, home, srv.URL+"/gateway", srv.URL+"/gateway/openapi.json") + + cmd := surfCmd(t, bin, home, nil, "market-price", "--nonexistent-flag") err := cmd.Run() wantExitCode(t, err, 1) }) } +func seedSurfSpecCache(t *testing.T, bin string, home string, base string, specFile string) { + t.Helper() + surfDir := filepath.Join(home, ".surf") + if err := os.MkdirAll(surfDir, 0o700); err != nil { + t.Fatal(err) + } + apisJSON := fmt.Sprintf(`{ + "$schema": "https://rest.sh/schemas/apis.json", + "surf": { + "base": %q, + "spec_files": [%q], + "profiles": {"default": {}} + } +}`, base, specFile) + if err := os.WriteFile(filepath.Join(surfDir, "apis.json"), []byte(apisJSON), 0o600); err != nil { + t.Fatal(err) + } + runSurf(t, bin, home, nil, "sync") + if _, err := os.Stat(filepath.Join(surfDir, "surf.cbor")); err != nil { + t.Fatalf("expected seeded surf.cbor cache: %v", err) + } + configBytes, err := os.ReadFile(filepath.Join(surfDir, "config.json")) + if err != nil { + t.Fatalf("expected seeded config.json: %v", err) + } + if !strings.Contains(string(configBytes), base) { + t.Fatalf("seeded cache base mismatch: want %q in config.json:\n%s", base, string(configBytes)) + } +} + // splitOutput wires the cmd's Stdout and Stderr to separate buffers. func splitOutput(cmd *exec.Cmd) (stdout, stderr *strBuf) { stdout = &strBuf{} @@ -123,12 +179,17 @@ func splitOutput(cmd *exec.Cmd) (stdout, stderr *strBuf) { type strBuf struct{ b []byte } func (s *strBuf) Write(p []byte) (int, error) { s.b = append(s.b, p...); return len(p), nil } -func (s *strBuf) Bytes() []byte { return s.b } -func (s *strBuf) String() string { return string(s.b) } +func (s *strBuf) Bytes() []byte { return s.b } +func (s *strBuf) String() string { return string(s.b) } // wantExitCode asserts that the error from cmd.Run() corresponds to the // expected exit code. nil err means exit 0. func wantExitCode(t *testing.T, err error, want int) { + t.Helper() + wantExitCodeWithStderr(t, err, want, "") +} + +func wantExitCodeWithStderr(t *testing.T, err error, want int, stderr string) { t.Helper() got := 0 if err != nil { @@ -139,6 +200,6 @@ func wantExitCode(t *testing.T, err error, want int) { } } if got != want { - t.Errorf("want exit code %d, got %d", want, got) + t.Errorf("want exit code %d, got %d\nstderr: %s", want, got, stderr) } } diff --git a/cmd/surf/usage_test.go b/cmd/surf/usage_test.go index 91a01b0..9a3e14d 100644 --- a/cmd/surf/usage_test.go +++ b/cmd/surf/usage_test.go @@ -1,11 +1,14 @@ package main import ( + "fmt" "net/http" "net/http/httptest" "os" "os/exec" + "path/filepath" "regexp" + "runtime" "strings" "testing" ) @@ -15,18 +18,188 @@ import ( // commands are registered from the cache. func buildSurfBin(t *testing.T) string { t.Helper() - bin := t.TempDir() + "/surf" + bin := buildBareSurfBin(t) + + home, _ := os.UserHomeDir() + if _, err := os.Stat(home + "/.surf/surf.cbor"); os.IsNotExist(err) { + t.Skip("no cached API spec — run `surf sync` first") + } + return bin +} + +func buildBareSurfBin(t *testing.T) string { + t.Helper() + binName := "surf" + if runtime.GOOS == "windows" { + binName += ".exe" + } + bin := filepath.Join(t.TempDir(), binName) build := exec.Command("go", "build", "-o", bin, ".") build.Dir = "." if out, err := build.CombinedOutput(); err != nil { t.Fatalf("build failed: %v\n%s", err, out) } + return bin +} - home, _ := os.UserHomeDir() - if _, err := os.Stat(home + "/.surf/surf.cbor"); os.IsNotExist(err) { - t.Skip("no cached API spec — run `surf sync` first") +func testOpenAPISpec(operationID, summary string) string { + return testOpenAPISpecWithServer(operationID, summary, "") +} + +func testOpenAPISpecWithServer(operationID, summary string, serverURL string) string { + serverBlock := "" + if serverURL != "" { + serverBlock = fmt.Sprintf(`, + "servers": [{"url": %q}]`, serverURL) + } + return fmt.Sprintf(`{ + "openapi": "3.0.0", + "info": {"title": "Surf Test API", "version": "1.0.0"}%s, + "paths": { + "/gateway/v1/%s": { + "get": { + "operationId": "%s", + "summary": "%s", + "tags": ["test"], + "parameters": [ + {"name": "address", "in": "query", "schema": {"type": "string"}} + ], + "responses": {"200": {"description": "ok"}} + } + } + } +}`, serverBlock, operationID, operationID, summary) +} + +func newSpecServer(t *testing.T, operationID string) (*httptest.Server, *[]string) { + t.Helper() + return newSpecAndOperationServer(t, operationID, nil) +} + +func newSpecAndOperationServer(t *testing.T, operationID string, operationHandler http.HandlerFunc) (*httptest.Server, *[]string) { + return newSpecAndOperationServerWithOpenAPIServer(t, operationID, "", operationHandler) +} + +func newSpecAndOperationServerWithOpenAPIServer(t *testing.T, operationID string, serverURL string, operationHandler http.HandlerFunc) (*httptest.Server, *[]string) { + t.Helper() + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if r.URL.Path == "/gateway/openapi.json" { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, testOpenAPISpecWithServer(operationID, operationID+" summary", serverURL)) + return + } + if operationHandler != nil { + operationHandler(w, r) + return + } + http.NotFound(w, r) + })) + return srv, &paths +} + +func surfCmd(t *testing.T, bin string, home string, env []string, args ...string) *exec.Cmd { + t.Helper() + cmd := exec.Command(bin, args...) + cmd.Env = append(os.Environ(), + "HOME="+home, + "USERPROFILE="+home, + "SURF_API_BASE_URL=", + "SURF_TELEMETRY_DISABLED=1", + "HTTP_PROXY=", + "HTTPS_PROXY=", + "ALL_PROXY=", + "NO_PROXY=*", + "http_proxy=", + "https_proxy=", + "all_proxy=", + "no_proxy=*", + ) + cmd.Env = append(cmd.Env, env...) + return cmd +} + +func runSurf(t *testing.T, bin string, home string, env []string, args ...string) string { + t.Helper() + cmd := surfCmd(t, bin, home, env, args...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("surf %s failed: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +func TestSyncUsesSurfAPIBaseURLEnv(t *testing.T) { + bin := buildBareSurfBin(t) + home := t.TempDir() + srv, paths := newSpecServer(t, "hyperliquidAccount") + defer srv.Close() + + env := []string{"SURF_API_BASE_URL=" + srv.URL + "/gateway/v1"} + out := runSurf(t, bin, home, env, "sync") + if !strings.Contains(out, "API spec synced.") { + t.Fatalf("sync output missing success message:\n%s", out) + } + if _, err := os.Stat(home + "/.surf/surf.cbor"); err != nil { + t.Fatalf("expected surf.cbor cache to be written: %v", err) + } + if got := strings.Join(*paths, ","); got != "/gateway/openapi.json" { + t.Fatalf("sync should fetch env-specific OpenAPI spec, got paths %q", got) + } + + out = runSurf(t, bin, home, env, "list-operations") + if !strings.Contains(out, "hyperliquid-account") { + t.Fatalf("list-operations missing env-specific operation:\n%s", out) + } +} + +func TestSyncUsesConfiguredSurfAPIBase(t *testing.T) { + bin := buildBareSurfBin(t) + home := t.TempDir() + surfDir := home + "/.surf" + if err := os.MkdirAll(surfDir, 0o700); err != nil { + t.Fatal(err) + } + srv, _ := newSpecServer(t, "configuredOnly") + defer srv.Close() + + apisJSON := fmt.Sprintf(`{ + "$schema": "https://rest.sh/schemas/apis.json", + "surf": { + "base": %q, + "spec_files": [%q], + "profiles": {"default": {}} + } +}`, srv.URL+"/gateway", srv.URL+"/gateway/openapi.json") + if err := os.WriteFile(surfDir+"/apis.json", []byte(apisJSON), 0o600); err != nil { + t.Fatal(err) + } + + runSurf(t, bin, home, nil, "sync") + out := runSurf(t, bin, home, nil, "list-operations") + if !strings.Contains(out, "configured-only") { + t.Fatalf("list-operations missing configured operation:\n%s", out) + } +} + +func TestCachedSpecIsScopedToSurfAPIBaseURL(t *testing.T) { + bin := buildBareSurfBin(t) + home := t.TempDir() + prodSrv, _ := newSpecServer(t, "prodOnly") + defer prodSrv.Close() + stgSrv, _ := newSpecServer(t, "stgOnly") + defer stgSrv.Close() + + runSurf(t, bin, home, []string{"SURF_API_BASE_URL=" + prodSrv.URL + "/gateway/v1"}, "sync") + + out := runSurf(t, bin, home, []string{"SURF_API_BASE_URL=" + stgSrv.URL + "/gateway/v1"}, "list-operations") + if !strings.Contains(out, "stg-only") { + t.Fatalf("expected cache miss and STG resync, got:\n%s", out) + } + if strings.Contains(out, "prod-only") { + t.Fatalf("reused cached operations from the previous base:\n%s", out) } - return bin } // TestNoDoubleSurfInUsage builds the surf binary and verifies that @@ -244,10 +417,15 @@ func TestVersionFlag(t *testing.T) { // TestDebugFlag verifies that --debug enables debug logging to stderr. func TestDebugFlag(t *testing.T) { - bin := buildSurfBin(t) + bin := buildBareSurfBin(t) + home := t.TempDir() + srv, _ := newSpecServer(t, "marketPrice") + defer srv.Close() - cmd := exec.Command(bin, "--debug", "market-price", "--symbol", "BTC", - "--surf-api-base-url", "http://127.0.0.1:1", "--rsh-retry", "0") + env := []string{"SURF_API_BASE_URL=" + srv.URL + "/gateway/v1"} + runSurf(t, bin, home, env, "sync") + + cmd := surfCmd(t, bin, home, env, "--debug", "market-price", "--address", "BTC", "--rsh-retry", "0") out, _ := cmd.CombinedOutput() if !strings.Contains(string(out), "DEBUG:") { t.Errorf("--debug should produce DEBUG: lines, got:\n%s", string(out)) @@ -256,10 +434,11 @@ func TestDebugFlag(t *testing.T) { // TestQuietFlag verifies that --quiet suppresses WARN/INFO but not errors. func TestQuietFlag(t *testing.T) { - bin := buildSurfBin(t) + bin := buildBareSurfBin(t) + home := t.TempDir() t.Run("errors still show", func(t *testing.T) { - cmd := exec.Command(bin, "--quiet", "market-price", "--bogus") + cmd := surfCmd(t, bin, home, nil, "--quiet", "auth", "--bogus") out, _ := cmd.CombinedOutput() if !strings.Contains(string(out), "unknown flag") { t.Errorf("--quiet should still show errors, got:\n%s", string(out)) @@ -267,24 +446,26 @@ func TestQuietFlag(t *testing.T) { }) // Spin up a server that always returns 429 to trigger retry WARN lines. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv, _ := newSpecAndOperationServer(t, "marketPrice", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) - })) + }) defer srv.Close() - args := []string{"market-price", "--symbol", "BTC", "--time-range", "1d", - "--surf-api-base-url", srv.URL, "--rsh-retry", "1"} + env := []string{"SURF_API_BASE_URL=" + srv.URL + "/gateway/v1"} + runSurf(t, bin, home, env, "sync") + + args := []string{"market-price", "--address", "BTC", "--rsh-retry", "1"} t.Run("without --quiet shows WARN", func(t *testing.T) { - out, _ := exec.Command(bin, args...).CombinedOutput() + out, _ := surfCmd(t, bin, home, env, args...).CombinedOutput() if !strings.Contains(string(out), "WARN:") { t.Errorf("expected WARN: line on 429 retry, got:\n%s", string(out)) } }) t.Run("--quiet suppresses WARN", func(t *testing.T) { - out, _ := exec.Command(bin, append([]string{"--quiet"}, args...)...).CombinedOutput() + out, _ := surfCmd(t, bin, home, env, append([]string{"--quiet"}, args...)...).CombinedOutput() if strings.Contains(string(out), "WARN:") { t.Errorf("--quiet should suppress WARN:, got:\n%s", string(out)) }