diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..49df101 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,81 @@ +name: Checks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + go: ["1.26.10"] + include: + - os: ubuntu-24.04 + go: "1.27.3" + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: ${{ matrix.go }} + - run: go mod verify + - run: go test -race -v ./... + - run: go vet ./... + - name: Check the executable + run: | + go run ./cmd/ipgeolocation --version + go run ./cmd/ipgeolocation --help + - name: Scan source dependencies + if: runner.os == 'Linux' + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.9.0 + govulncheck ./... + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + if: runner.os == 'Linux' + with: + node-version: "24.22.1" + - run: npm install --global postman-cli@1.73.0 + if: runner.os == 'Linux' + - run: node scripts/test-http.cjs + if: runner.os == 'Linux' + timeout-minutes: 3 + - name: Verify module files + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + build: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.26.10" + - run: go install golang.org/x/vuln/cmd/govulncheck@v1.9.0 + - run: bash build-release.sh + - name: Check the Linux archive + run: | + mkdir "$RUNNER_TEMP/cli" + tar -xzf dist/*-linux-amd64.tar.gz -C "$RUNNER_TEMP/cli" + "$RUNNER_TEMP"/cli/* --version + "$RUNNER_TEMP"/cli/* --help + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: cli-archives + path: dist/* + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..860ff80 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.26.10" + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24.22.1" + - run: npm install --global postman-cli@1.73.0 + - run: node scripts/test-http.cjs + timeout-minutes: 3 + - run: go install golang.org/x/vuln/cmd/govulncheck@v1.9.0 + - run: bash build-release.sh + - name: Publish archives and checksums + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --title "$GITHUB_REF_NAME" --generate-notes diff --git a/README.md b/README.md index fa2eb09..7321cb9 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Based on: ## Requirements -- Go 1.18+ +- Go 1.26.10 or Go 1.27.3, or a newer patched stable version (only for building from source) - API Key from [IPGeolocation.io](https://ipgeolocation.io) ## Installation @@ -140,10 +140,21 @@ go build -o ipgeolocation ./cmd/ipgeolocation ``` Here, you will need to add `$GOBIN` or `$GOPATH/bin` to your `PATH` as well. +To build release archives for all supported platforms, install `govulncheck` and run: + +```bash +go install golang.org/x/vuln/cmd/govulncheck@v1.9.0 +bash build-release.sh +``` + +The script runs tests, checks the source and each binary, and writes archives and `SHA256SUMS` to `dist/`. Tagged releases run the same checks before publishing. + +For local HTTPS regression checks, install Node.js and Postman CLI 1.73.0, then run `node scripts/test-http.cjs`. + ### Download Prebuilt Binaries #### Overview -These are prebuilt binaries for the IPGeolocation CLI tool, version **2.1.0**. Users can download these files directly from [GitHub Releases](https://github.com/IPGeolocation/cli/releases) or from the table below without needing to build from source. +These are prebuilt binaries for the IPGeolocation CLI tool, version **2.1.1**. Users can download these files directly from [GitHub Releases](https://github.com/IPGeolocation/cli/releases) or from the table below without needing to build from source. The CLI provides geolocation information, timezone, user-agent parsing, bulk IP lookups, and more. @@ -151,11 +162,13 @@ The CLI provides geolocation information, timezone, user-agent parsing, bulk IP | Platform | Architecture | File Name / Download Link | |----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------| -| Linux | amd64 | [**ipgeolocation-2.1.0-linux-amd64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.0/ipgeolocation-2.1.0-linux-amd64.tar.gz) | -| Linux | arm64 | [**ipgeolocation-2.1.0-linux-arm64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.0/ipgeolocation-2.1.0-linux-arm64.tar.gz) | -| macOS | amd64 | [**ipgeolocation-2.1.0-darwin-amd64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.0/ipgeolocation-2.1.0-darwin-amd64.tar.gz) | -| macOS | arm64 | [**ipgeolocation-2.1.0-darwin-arm64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.0/ipgeolocation-2.1.0-darwin-arm64.tar.gz) | -| Windows | amd64 | [**ipgeolocation-2.1.0-windows-amd64.zip**](https://github.com/IPGeolocation/cli/releases/download/v2.1.0/ipgeolocation-2.1.0-windows-amd64.zip) | +| Linux | amd64 | [**ipgeolocation-2.1.1-linux-amd64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/ipgeolocation-2.1.1-linux-amd64.tar.gz) | +| Linux | arm64 | [**ipgeolocation-2.1.1-linux-arm64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/ipgeolocation-2.1.1-linux-arm64.tar.gz) | +| macOS | amd64 | [**ipgeolocation-2.1.1-darwin-amd64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/ipgeolocation-2.1.1-darwin-amd64.tar.gz) | +| macOS | arm64 | [**ipgeolocation-2.1.1-darwin-arm64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/ipgeolocation-2.1.1-darwin-arm64.tar.gz) | +| Windows | amd64 | [**ipgeolocation-2.1.1-windows-amd64.zip**](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/ipgeolocation-2.1.1-windows-amd64.zip) | + +Archives include [SHA256SUMS](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/SHA256SUMS) for download verification. #### Installation Instructions @@ -163,11 +176,11 @@ The CLI provides geolocation information, timezone, user-agent parsing, bulk IP 1. Download the `.tar.gz` file for your architecture. 2. Extract it to a folder in your PATH, e.g., `/usr/local/bin`: ```bash - tar -xzf ipgeolocation-2.1.0-linux-amd64.tar.gz -C /usr/local/bin + tar -xzf ipgeolocation-2.1.1-linux-amd64.tar.gz -C /usr/local/bin ``` 3. Rename the binary for simplicity: ```bash - mv /usr/local/bin/ipgeolocation-2.1.0-linux-amd64 /usr/local/bin/ipgeolocation + mv /usr/local/bin/ipgeolocation-2.1.1-linux-amd64 /usr/local/bin/ipgeolocation ``` 4. Make the binary executable: ```bash @@ -182,11 +195,11 @@ The CLI provides geolocation information, timezone, user-agent parsing, bulk IP 1. Download the `.tar.gz` file for your architecture (amd64 or arm64). 2. Extract to a folder in your PATH, e.g., `/usr/local/bin`: ```bash - tar -xzf ipgeolocation-2.1.0-darwin-amd64.tar.gz -C /usr/local/bin + tar -xzf ipgeolocation-2.1.1-darwin-amd64.tar.gz -C /usr/local/bin ``` 3. Rename the binary: ```bash - mv /usr/local/bin/ipgeolocation-2.1.0-darwin-amd64 /usr/local/bin/ipgeolocation + mv /usr/local/bin/ipgeolocation-2.1.1-darwin-amd64 /usr/local/bin/ipgeolocation ``` 4. Make executable: ```bash @@ -199,7 +212,7 @@ The CLI provides geolocation information, timezone, user-agent parsing, bulk IP **Windows:** 1. Download the `.zip` file. -2. Extract the `ipgeolocation-2.1.0-windows-amd64.exe` to a folder included in your system `PATH`. +2. Extract the `ipgeolocation-2.1.1-windows-amd64.exe` to a folder included in your system `PATH`. 3. Rename the binary to `ipgeolocation.exe` for convenience. 4. Open Command Prompt and verify: ```cmd @@ -209,7 +222,7 @@ The CLI provides geolocation information, timezone, user-agent parsing, bulk IP > [!NOTE] > - Ensure execution permissions on Linux/macOS. > - Recommended folder for binaries: `/usr/local/bin` or any folder in your PATH. -> - Prebuilt binaries include version **2.1.0** in their filename. Rename them after extraction for easier use. +> - Prebuilt binaries include version **2.1.1** in their filename. Rename them after extraction for easier use. > - For updates, check GitHub Releases. ### Troubleshooting diff --git a/build-release.sh b/build-release.sh index d6f59a9..418bdfb 100755 --- a/build-release.sh +++ b/build-release.sh @@ -1,44 +1,70 @@ #!/usr/bin/env bash -set -e +set -euo pipefail +cd -- "$(dirname -- "${BASH_SOURCE[0]}")" APP_NAME="ipgeolocation" -VERSION="2.1.0" # fixed version + +compiler=$(go env GOVERSION) +if [[ ! "$compiler" =~ ^go1\.([0-9]+)\.([0-9]+)$ ]] || + (( BASH_REMATCH[1] < 26 || (BASH_REMATCH[1] == 26 && BASH_REMATCH[2] < 10) || (BASH_REMATCH[1] == 27 && BASH_REMATCH[2] < 3) )); then + echo "Use Go 1.26.10, Go 1.27.3, or a newer patched stable version." >&2 + exit 1 +fi +command -v govulncheck >/dev/null || { echo "Install govulncheck: go install golang.org/x/vuln/cmd/govulncheck@v1.9.0" >&2; exit 1; } + +version_output=$(go run ./cmd/ipgeolocation --version) +VERSION="${version_output##* }" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid CLI version: $VERSION" >&2; exit 1; } +if [[ "${GITHUB_REF_TYPE:-}" == "tag" && "${GITHUB_REF_NAME:-}" != "v$VERSION" ]]; then + echo "Release tag must match the CLI version v$VERSION." >&2 + exit 1 +fi + +go mod verify +go test -race ./... +go vet ./... +govulncheck ./... rm -rf dist mkdir -p dist - -# build targets targets=( - "linux amd64" - "linux arm64" - "darwin amd64" - "darwin arm64" - "windows amd64" + "linux/amd64" + "linux/arm64" + "darwin/amd64" + "darwin/arm64" + "windows/amd64" ) -echo "🚀 Building $APP_NAME $VERSION ..." +echo "Building $APP_NAME $VERSION with $compiler ..." for target in "${targets[@]}"; do - os=$(echo $target | cut -d' ' -f1) - arch=$(echo $target | cut -d' ' -f2) - + IFS=/ read -r os arch <<< "$target" output="${APP_NAME}-${VERSION}-${os}-${arch}" - if [ "$os" == "windows" ]; then + if [[ "$os" == "windows" ]]; then output="$output.exe" fi - echo "👉 Building for $os/$arch ..." - GOOS=$os GOARCH=$arch go build -o dist/$output ./cmd/$APP_NAME + echo "Building and scanning $target ..." + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath -o "dist/$output" "./cmd/$APP_NAME" + govulncheck -mode=binary "dist/$output" - # package - if [ "$os" == "windows" ]; then - zip -j dist/${output%.exe}.zip dist/$output - rm dist/$output + if [[ "$os" == "windows" ]]; then + zip -j "dist/${output%.exe}.zip" "dist/$output" else - tar -czf dist/$output.tar.gz -C dist $(basename $output) - rm dist/$output + COPYFILE_DISABLE=1 tar -czf "dist/$output.tar.gz" -C dist "$output" fi + rm "dist/$output" done -echo "Done! Binaries are in ./dist" +( + cd dist + if command -v sha256sum >/dev/null; then + sha256sum ./*.tar.gz ./*.zip > SHA256SUMS + sha256sum -c SHA256SUMS + else + shasum -a 256 ./*.tar.gz ./*.zip > SHA256SUMS + shasum -a 256 -c SHA256SUMS + fi +) +echo "Archives and SHA256SUMS are in ./dist" diff --git a/cmd/abuse.go b/cmd/abuse.go index 73d8cfb..6f8aef7 100644 --- a/cmd/abuse.go +++ b/cmd/abuse.go @@ -3,8 +3,7 @@ package cmd import ( "encoding/json" "fmt" - "io" - "net/http" + neturl "net/url" "strings" "github.com/IPGeolocation/cli/v2/internal/common" @@ -37,32 +36,24 @@ Example: } baseURL := "https://api.ipgeolocation.io/v3/abuse" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) if abuseFlags.IP != "" { - url += "&ip=" + abuseFlags.IP + url += "&ip=" + neturl.QueryEscape(abuseFlags.IP) } if len(abuseFlags.Excludes) > 0 { - url += "&excludes=" + strings.Join(abuseFlags.Excludes, ",") + url += "&excludes=" + neturl.QueryEscape(strings.Join(abuseFlags.Excludes, ",")) } if len(abuseFlags.Fields) > 0 { - url += "&fields=" + strings.Join(abuseFlags.Fields, ",") + url += "&fields=" + neturl.QueryEscape(strings.Join(abuseFlags.Fields, ",")) } - resp, err := http.Get(url) + body, err := utils.Get(url) if err != nil { fmt.Println("Error fetching abuse info:", err) return } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != 200 { - fmt.Printf("Error: %s\n", string(body)) - return - } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { diff --git a/cmd/asn.go b/cmd/asn.go index e383b4a..aab6e32 100644 --- a/cmd/asn.go +++ b/cmd/asn.go @@ -3,8 +3,7 @@ package cmd import ( "encoding/json" "fmt" - "io" - "net/http" + neturl "net/url" "strings" "github.com/IPGeolocation/cli/v2/internal/common" @@ -39,40 +38,32 @@ Getting peering relationships for an ASN number: ipgeolocation asn --asn 12345 - } baseURL := "https://api.ipgeolocation.io/v3/asn" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) if asnFlags.IP != "" { - url += "&ip=" + asnFlags.IP + url += "&ip=" + neturl.QueryEscape(asnFlags.IP) } if asnFlags.ASN != "" { - url += "&asn=" + asnFlags.ASN + url += "&asn=" + neturl.QueryEscape(asnFlags.ASN) } if len(asnFlags.Include) > 0 { - url += "&include=" + strings.Join(asnFlags.Include, ",") + url += "&include=" + neturl.QueryEscape(strings.Join(asnFlags.Include, ",")) } if len(asnFlags.Excludes) > 0 { - url += "&excludes=" + strings.Join(asnFlags.Excludes, ",") + url += "&excludes=" + neturl.QueryEscape(strings.Join(asnFlags.Excludes, ",")) } if len(asnFlags.Fields) > 0 { - url += "&fields=" + strings.Join(asnFlags.Fields, ",") + url += "&fields=" + neturl.QueryEscape(strings.Join(asnFlags.Fields, ",")) } - resp, err := http.Get(url) + body, err := utils.Get(url) if err != nil { fmt.Println("Error fetching ASN info:", err) return } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != 200 { - fmt.Printf("Error: %s\n", string(body)) - return - } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { diff --git a/cmd/astronomy.go b/cmd/astronomy.go index 9fe25d2..50b01fc 100644 --- a/cmd/astronomy.go +++ b/cmd/astronomy.go @@ -3,9 +3,7 @@ package cmd import ( "encoding/json" "fmt" - "io" - "net/http" - encoding "net/url" + neturl "net/url" "github.com/IPGeolocation/cli/v2/internal/common" "github.com/IPGeolocation/cli/v2/internal/config" @@ -55,21 +53,21 @@ API Reference: https://ipgeolocation.io/astronomy-api.html } baseURL := "https://api.ipgeolocation.io/v3/astronomy" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) if astronomyFlags.IP != "" { - url += "&ip=" + astronomyFlags.IP + url += "&ip=" + neturl.QueryEscape(astronomyFlags.IP) } if astronomyFlags.Tz != "" { - url += "&time_zone=" + astronomyFlags.Tz + url += "&time_zone=" + neturl.QueryEscape(astronomyFlags.Tz) } if astronomyFlags.Language != "" { - url += "&lang=" + astronomyFlags.Language + url += "&lang=" + neturl.QueryEscape(astronomyFlags.Language) } if astronomyFlags.Location != "" { - url += "&location=" + encoding.QueryEscape(astronomyFlags.Location) + url += "&location=" + neturl.QueryEscape(astronomyFlags.Location) } if astronomyFlags.Latitude != 0 { @@ -84,19 +82,11 @@ API Reference: https://ipgeolocation.io/astronomy-api.html url += "&elevation=" + fmt.Sprintf("%f", astronomyFlags.Elevation) } - resp, err := http.Get(url) + body, err := utils.Get(url) if err != nil { fmt.Println("Error fetching astronomy info:", err) return } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != 200 { - fmt.Printf("Error: %s\n", string(body)) - return - } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { diff --git a/cmd/astronomyTimeseries.go b/cmd/astronomyTimeseries.go index ff3091c..6b3a6e8 100644 --- a/cmd/astronomyTimeseries.go +++ b/cmd/astronomyTimeseries.go @@ -3,9 +3,7 @@ package cmd import ( "encoding/json" "fmt" - "io" - "net/http" - encoding "net/url" + neturl "net/url" "github.com/IPGeolocation/cli/v2/internal/common" "github.com/IPGeolocation/cli/v2/internal/config" @@ -44,7 +42,7 @@ Examples: } baseURL := "https://api.ipgeolocation.io/v3/astronomy/timeSeries" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) if astronomyTimeseriesFlags.DateStart == "" || astronomyTimeseriesFlags.DateEnd == "" { fmt.Println("Please provide both start and end dates.") @@ -52,23 +50,23 @@ Examples: } if astronomyTimeseriesFlags.DateStart != "" { - url += "&dateStart=" + astronomyTimeseriesFlags.DateStart + url += "&dateStart=" + neturl.QueryEscape(astronomyTimeseriesFlags.DateStart) } if astronomyTimeseriesFlags.DateEnd != "" { - url += "&dateEnd=" + astronomyTimeseriesFlags.DateEnd + url += "&dateEnd=" + neturl.QueryEscape(astronomyTimeseriesFlags.DateEnd) } if astronomyTimeseriesFlags.IP != "" { - url += "&ip=" + astronomyTimeseriesFlags.IP + url += "&ip=" + neturl.QueryEscape(astronomyTimeseriesFlags.IP) } if astronomyTimeseriesFlags.Language != "" { - url += "&lang=" + astronomyTimeseriesFlags.Language + url += "&lang=" + neturl.QueryEscape(astronomyTimeseriesFlags.Language) } if astronomyTimeseriesFlags.Location != "" { - url += "&location=" + encoding.QueryEscape(astronomyTimeseriesFlags.Location) + url += "&location=" + neturl.QueryEscape(astronomyTimeseriesFlags.Location) } if astronomyTimeseriesFlags.Latitude != 0 { @@ -79,19 +77,11 @@ Examples: url += "&long=" + fmt.Sprintf("%f", astronomyTimeseriesFlags.Longitude) } - resp, err := http.Get(url) + body, err := utils.Get(url) if err != nil { fmt.Println("Error fetching astronomy time-series info:", err) return } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != 200 { - fmt.Printf("Error: %s\n", string(body)) - return - } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { diff --git a/cmd/bulkIpSecurity.go b/cmd/bulkIpSecurity.go index 22ed572..5baa1e4 100644 --- a/cmd/bulkIpSecurity.go +++ b/cmd/bulkIpSecurity.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/json" "fmt" + neturl "net/url" "os" "strings" @@ -64,14 +65,14 @@ Retrieving security information for IP addresses from a file: ipgeolocation bulk } baseURL := "https://api.ipgeolocation.io/v3/security-bulk" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) if len(bulkSecurityFlags.Excludes) > 0 { - url += "&excludes=" + strings.Join(bulkSecurityFlags.Excludes, ",") + url += "&excludes=" + neturl.QueryEscape(strings.Join(bulkSecurityFlags.Excludes, ",")) } if len(bulkSecurityFlags.Fields) > 0 { - url += "&fields=" + strings.Join(bulkSecurityFlags.Fields, ",") + url += "&fields=" + neturl.QueryEscape(strings.Join(bulkSecurityFlags.Fields, ",")) } payload := map[string]interface{}{ diff --git a/cmd/commands_test.go b/cmd/commands_test.go new file mode 100644 index 0000000..5de8e42 --- /dev/null +++ b/cmd/commands_test.go @@ -0,0 +1,239 @@ +package cmd + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const testKey = "TEST_KEY &+?=#%" + +type fixtureTransport struct{} + +func (fixtureTransport) RoundTrip(r *http.Request) (*http.Response, error) { + key := r.URL.Query().Get("apiKey") + status := map[string]int{"BAD_KEY": 401, "FORBIDDEN": 403, "RATE_LIMIT": 429, "SERVER_ERROR": 503}[key] + body := "{" + if status != 0 { + body = fmt.Sprintf(`{"message":"Test failure for %s"}`, key) + } else if key != "BAD_JSON" { + status = 200 + var payload interface{} + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + return nil, err + } + } + result := map[string]interface{}{ + "ip": "2001:db8::1", "location": map[string]string{"city": "Test City"}, + "_request": map[string]interface{}{"method": r.Method, "path": r.URL.Path, "query": r.URL.Query(), "payload": payload}, + } + var response interface{} = result + if strings.HasSuffix(r.URL.Path, "-bulk") { + response = []interface{}{result} + } + encoded, err := json.Marshal(response) + if err != nil { + return nil, err + } + body = string(encoded) + } else { + status = 200 + } + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil +} + +// Each command runs in its own process so Cobra's global flags and saved keys cannot leak between cases. +func TestCLIProcess(t *testing.T) { + if os.Getenv("CLI_TEST_PROCESS") != "1" { + return + } + if os.Getenv("CLI_HTTP_TEST") == "1" { + certificate, err := os.ReadFile(os.Getenv("SSL_CERT_FILE")) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(certificate) { + t.Fatal("invalid simulation CA") + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{RootCAs: roots} + http.DefaultClient.Transport = transport + } else { + http.DefaultClient.Transport = fixtureTransport{} + } + for i, arg := range os.Args { + if arg == "--" { + rootCmd.SetArgs(os.Args[i+1:]) + Execute() + os.Exit(0) + } + } + t.Fatal("missing CLI arguments") +} + +func runCLI(t *testing.T, home string, args ...string) string { + t.Helper() + process := exec.Command(os.Args[0], append([]string{"-test.run=^TestCLIProcess$", "--"}, args...)...) + if binary := os.Getenv("CLI_TEST_BINARY"); binary != "" { + process = exec.Command(binary, args...) + } + process.Dir = home + process.Env = append(os.Environ(), "CLI_TEST_PROCESS=1", "HOME="+home, "USERPROFILE="+home, "GORACE=atexit_sleep_ms=0") + output, err := process.CombinedOutput() + if err != nil { + t.Fatalf("CLI %v failed: %v\n%s", args, err, output) + } + return strings.TrimSpace(string(output)) +} + +func configuredHome(t *testing.T, key string) string { + t.Helper() + home := t.TempDir() + if output := runCLI(t, home, "config", "--apikey", key); output != "✅ API key saved securely." { + t.Fatalf("config failed: %s", output) + } + return home +} + +func TestCLICommands(t *testing.T) { + home := configuredHome(t, testKey) + cases := []struct { + name, path string + flags []string + query url.Values + payload interface{} + }{ + {"my-ip", "/getip", nil, url.Values{}, nil}, + {"getip", "/getip", nil, url.Values{}, nil}, + {"ipgeo", "/v3/ipgeo", []string{"--ip", "2001:db8::1", "--include", "security,time_zone", "--exclude", "currency", "--fields", "ip,location", "--lang", "en"}, url.Values{"ip": {"2001:db8::1"}, "include": {"security,time_zone"}, "excludes": {"currency"}, "fields": {"ip,location"}, "lang": {"en"}}, nil}, + {"ipgeo", "/v3/ipgeo", []string{"--ip", "example.test&injected=yes+#?%", "--excludes", "currency"}, url.Values{"ip": {"example.test&injected=yes+#?%"}, "excludes": {"currency"}}, nil}, + {"ip-security", "/v3/security", []string{"--ip", "8.8.8.8", "--exclude", "country", "--fields", "ip,security"}, url.Values{"ip": {"8.8.8.8"}, "excludes": {"country"}, "fields": {"ip,security"}}, nil}, + {"abuse", "/v3/abuse", []string{"--ip", "example.test", "--exclude", "country", "--fields", "ip,abuse"}, url.Values{"ip": {"example.test"}, "excludes": {"country"}, "fields": {"ip,abuse"}}, nil}, + {"asn", "/v3/asn", []string{"--ip", "8.8.8.8", "--asn", "AS15169", "--include", "peers,routes", "--exclude", "whois", "--fields", "asn"}, url.Values{"ip": {"8.8.8.8"}, "asn": {"AS15169"}, "include": {"peers,routes"}, "excludes": {"whois"}, "fields": {"asn"}}, nil}, + {"timezone", "/v3/timezone", []string{"--ip", "8.8.8.8", "--iata", "JFK", "--icao", "KJFK", "--lo", "USNYC", "--lang", "en", "--tz", "America/New_York", "--location", "New York & Queens", "--latitude", "40.7", "--longitude", "-74"}, url.Values{"ip": {"8.8.8.8"}, "iata_code": {"JFK"}, "icao_code": {"KJFK"}, "lo_code": {"USNYC"}, "lang": {"en"}, "tz": {"America/New_York"}, "location": {"New York & Queens"}, "lat": {"40.700000"}, "long": {"-74.000000"}}, nil}, + {"time-conversion", "/v3/timezone/convert", []string{"--tz_from", "America/New_York", "--tz_to", "Asia/Tokyo", "--location_from", "New York & Queens", "--location_to", "Tokyo", "--lat_from", "40.7", "--long_from", "-74", "--lat_to", "35.6", "--long_to", "139.7", "--iata_from", "JFK", "--iata_to", "HND", "--icao_from", "KJFK", "--icao_to", "RJTT", "--lo_from", "USNYC", "--lo_to", "JPTYO", "--time", "2026-10-29 15:00:00+05:00"}, url.Values{"tz_from": {"America/New_York"}, "tz_to": {"Asia/Tokyo"}, "location_from": {"New York & Queens"}, "location_to": {"Tokyo"}, "lat_from": {"40.700000"}, "long_from": {"-74.000000"}, "lat_to": {"35.600000"}, "long_to": {"139.700000"}, "iata_from": {"JFK"}, "iata_to": {"HND"}, "icao_from": {"KJFK"}, "icao_to": {"RJTT"}, "locode_from": {"USNYC"}, "locode_to": {"JPTYO"}, "time": {"2026-10-29 15:00:00+05:00"}}, nil}, + {"astronomy", "/v3/astronomy", []string{"--ip", "8.8.8.8", "--tz", "America/New_York", "--lang", "en", "--location", "New York & Queens", "--latitude", "40.7", "--longitude", "-74", "--elevation", "20.5"}, url.Values{"ip": {"8.8.8.8"}, "time_zone": {"America/New_York"}, "lang": {"en"}, "location": {"New York & Queens"}, "lat": {"40.700000"}, "long": {"-74.000000"}, "elevation": {"20.500000"}}, nil}, + {"astronomy-timeseries", "/v3/astronomy/timeSeries", []string{"--ip", "2001:db8::1", "--start-date", "2026-10-29", "--end-date", "2026-10-30", "--lang", "en", "--location", "New York & Queens", "--latitude", "40.7", "--longitude", "-74"}, url.Values{"ip": {"2001:db8::1"}, "dateStart": {"2026-10-29"}, "dateEnd": {"2026-10-30"}, "lang": {"en"}, "location": {"New York & Queens"}, "lat": {"40.700000"}, "long": {"-74.000000"}}, nil}, + {"bulk-ip-geo", "/v3/ipgeo-bulk", []string{"--ips", "8.8.8.8,2001:db8::1", "--include", "security,time_zone", "--exclude", "currency", "--fields", "ip,location", "--lang", "en"}, url.Values{"include": {"security,time_zone"}, "excludes": {"currency"}, "fields": {"ip,location"}, "lang": {"en"}}, map[string]interface{}{"ips": []interface{}{"8.8.8.8", "2001:db8::1"}}}, + {"bulk-ip-security", "/v3/security-bulk", []string{"--ips", "8.8.8.8,2001:db8::1", "--exclude", "country", "--fields", "ip,security"}, url.Values{"excludes": {"country"}, "fields": {"ip,security"}}, map[string]interface{}{"ips": []interface{}{"8.8.8.8", "2001:db8::1"}}}, + {"parse-user-agent", "/v3/user-agent", []string{"--user-agent", `Agent/1.0 "quoted" + &`}, url.Values{}, map[string]interface{}{"uaString": `Agent/1.0 "quoted" + &`}}, + {"parse-bulk-user-agents", "/v3/user-agent-bulk", []string{"--user-agents", "Agent/1.0,curl/8.0"}, url.Values{}, map[string]interface{}{"uaStrings": []interface{}{"Agent/1.0", "curl/8.0"}}}, + } + for _, tc := range cases { + if tc.path != "/getip" { + tc.query.Set("apiKey", testKey) + } + for _, format := range []string{"raw", "pretty", "yaml", "table"} { + t.Run(tc.name+"/"+format, func(t *testing.T) { + args := append([]string{tc.name}, tc.flags...) + output := runCLI(t, home, append(args, "--output", format)...) + if format == "table" { + if !strings.Contains(output, "2001:db8::1") || !strings.Contains(output, "Test City") { + t.Fatalf("table lost result fields: %s", output) + } + return + } + var result interface{} + var err error + if format == "yaml" { + err = yaml.Unmarshal([]byte(output), &result) + } else { + err = json.Unmarshal([]byte(output), &result) + } + if err != nil { + t.Fatalf("invalid %s output: %v\n%s", format, err, output) + } + if strings.HasSuffix(tc.path, "-bulk") { + result = result.([]interface{})[0] + } + object := result.(map[string]interface{}) + if object["ip"] != "2001:db8::1" { + t.Fatalf("result lost IP: %v", object) + } + actual := object["_request"].(map[string]interface{}) + method := "GET" + if tc.payload != nil { + method = "POST" + } + expected := map[string]interface{}{"method": method, "path": tc.path, "query": tc.query, "payload": tc.payload} + encoded, _ := json.Marshal(expected) + var normalized map[string]interface{} + json.Unmarshal(encoded, &normalized) + if !reflect.DeepEqual(actual, normalized) { + t.Fatalf("request changed:\ngot %v\nwant %v", actual, normalized) + } + }) + } + } + if output := runCLI(t, home, "getip", "--output", "text"); output != "2001:db8::1" { + t.Fatalf("text output changed: %s", output) + } +} + +func TestCLIHTTPErrors(t *testing.T) { + for _, key := range []string{"BAD_KEY", "FORBIDDEN", "RATE_LIMIT", "SERVER_ERROR", "BAD_JSON"} { + t.Run(key, func(t *testing.T) { + home := configuredHome(t, key) + for _, args := range [][]string{{"ipgeo"}, {"parse-user-agent", "--user-agent", "test"}} { + output := runCLI(t, home, args...) + if !strings.Contains(output, "Error") || strings.Contains(output, key) || strings.Contains(output, `"_request"`) { + t.Fatalf("unsafe or missing API error: %s", output) + } + } + }) + } +} + +func TestCLIConfigAndBulkFiles(t *testing.T) { + home := configuredHome(t, "short") + if output := runCLI(t, home, "config"); strings.Contains(output, "short") || !strings.Contains(output, "********") { + t.Fatalf("short key exposed: %s", output) + } + if output := runCLI(t, home, "--version"); output != "ipgeolocation version 2.1.1" { + t.Fatalf("incorrect version: %s", output) + } + if output := runCLI(t, home, "--help"); !strings.Contains(output, "time-conversion") { + t.Fatalf("help lost commands: %s", output) + } + if output := runCLI(t, t.TempDir(), "ipgeo"); !strings.Contains(output, "API key not found") { + t.Fatalf("missing config not reported: %s", output) + } + if output := runCLI(t, home, "astronomy-timeseries"); !strings.Contains(output, "both start and end dates") { + t.Fatalf("missing dates not reported: %s", output) + } + if err := os.WriteFile(filepath.Join(home, "ips.txt"), []byte(" 2001:db8::1 \n\n1.1.1.1\n"), 0600); err != nil { + t.Fatal(err) + } + for _, command := range []string{"bulk-ip-geo", "bulk-ip-security"} { + output := runCLI(t, home, command, "--ips", "8.8.8.8", "--file", "ips.txt", "--output-file", command) + if !strings.Contains(output, "Output saved to file:") { + t.Fatalf("bulk file failed: %s", output) + } + data, err := os.ReadFile(filepath.Join(home, command+".json")) + if err != nil { + t.Fatal(err) + } + var results []map[string]interface{} + if err := json.Unmarshal(data, &results); err != nil { + t.Fatal(err) + } + payload := results[0]["_request"].(map[string]interface{})["payload"].(map[string]interface{}) + if !reflect.DeepEqual(payload["ips"], []interface{}{"8.8.8.8", "2001:db8::1", "1.1.1.1"}) { + t.Fatalf("bulk inputs changed: %v", payload) + } + } +} diff --git a/cmd/config.go b/cmd/config.go index a9dad4b..ff78e59 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -46,8 +46,6 @@ If no flag is passed, the currently stored API key will be displayed.`, masked := "********" if len(cfg.ApiKey) > 5 { masked += cfg.ApiKey[len(cfg.ApiKey)-5:] - } else { - masked += cfg.ApiKey } fmt.Println("🔐 Current API key:", masked) diff --git a/cmd/ipSecurity.go b/cmd/ipSecurity.go index 98dec4c..32fe026 100644 --- a/cmd/ipSecurity.go +++ b/cmd/ipSecurity.go @@ -3,8 +3,7 @@ package cmd import ( "encoding/json" "fmt" - "io" - "net/http" + neturl "net/url" "strings" "github.com/IPGeolocation/cli/v2/internal/common" @@ -47,32 +46,24 @@ Notes: } baseURL := "https://api.ipgeolocation.io/v3/security" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) if securityFlags.IP != "" { - url += "&ip=" + securityFlags.IP + url += "&ip=" + neturl.QueryEscape(securityFlags.IP) } if len(securityFlags.Excludes) > 0 { - url += "&excludes=" + strings.Join(securityFlags.Excludes, ",") + url += "&excludes=" + neturl.QueryEscape(strings.Join(securityFlags.Excludes, ",")) } if len(securityFlags.Fields) > 0 { - url += "&fields=" + strings.Join(securityFlags.Fields, ",") + url += "&fields=" + neturl.QueryEscape(strings.Join(securityFlags.Fields, ",")) } - resp, err := http.Get(url) + body, err := utils.Get(url) if err != nil { fmt.Println("Error fetching ip security info:", err) return } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != 200 { - fmt.Printf("Error: %s\n", string(body)) - return - } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { diff --git a/cmd/ipgeo.go b/cmd/ipgeo.go index 2153652..de0f56b 100644 --- a/cmd/ipgeo.go +++ b/cmd/ipgeo.go @@ -3,8 +3,7 @@ package cmd import ( "encoding/json" "fmt" - "io" - "net/http" + neturl "net/url" "strings" "github.com/IPGeolocation/cli/v2/internal/common" @@ -58,40 +57,32 @@ Notes: } baseURL := "https://api.ipgeolocation.io/v3/ipgeo" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) if ipgeoFlags.IP != "" { - url += "&ip=" + ipgeoFlags.IP + url += "&ip=" + neturl.QueryEscape(ipgeoFlags.IP) } if len(ipgeoFlags.Include) > 0 { - url += "&include=" + strings.Join(ipgeoFlags.Include, ",") + url += "&include=" + neturl.QueryEscape(strings.Join(ipgeoFlags.Include, ",")) } if len(ipgeoFlags.Excludes) > 0 { - url += "&excludes=" + strings.Join(ipgeoFlags.Excludes, ",") + url += "&excludes=" + neturl.QueryEscape(strings.Join(ipgeoFlags.Excludes, ",")) } if len(ipgeoFlags.Fields) > 0 { - url += "&fields=" + strings.Join(ipgeoFlags.Fields, ",") + url += "&fields=" + neturl.QueryEscape(strings.Join(ipgeoFlags.Fields, ",")) } if ipgeoFlags.Language != "" { - url += "&lang=" + ipgeoFlags.Language + url += "&lang=" + neturl.QueryEscape(ipgeoFlags.Language) } - resp, err := http.Get(url) + body, err := utils.Get(url) if err != nil { fmt.Println("Error fetching IP Geolocation info:", err) return } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != 200 { - fmt.Printf("Error: %s\n", string(body)) - return - } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { diff --git a/cmd/ipgeoBulk.go b/cmd/ipgeoBulk.go index c7ae130..495703a 100644 --- a/cmd/ipgeoBulk.go +++ b/cmd/ipgeoBulk.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/json" "fmt" + neturl "net/url" "os" "strings" @@ -72,19 +73,19 @@ Examples: } baseURL := "https://api.ipgeolocation.io/v3/ipgeo-bulk" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) if len(bulkIpgeoFlags.Include) > 0 { - url += "&include=" + strings.Join(bulkIpgeoFlags.Include, ",") + url += "&include=" + neturl.QueryEscape(strings.Join(bulkIpgeoFlags.Include, ",")) } if len(bulkIpgeoFlags.Excludes) > 0 { - url += "&excludes=" + strings.Join(bulkIpgeoFlags.Excludes, ",") + url += "&excludes=" + neturl.QueryEscape(strings.Join(bulkIpgeoFlags.Excludes, ",")) } if len(bulkIpgeoFlags.Fields) > 0 { - url += "&fields=" + strings.Join(bulkIpgeoFlags.Fields, ",") + url += "&fields=" + neturl.QueryEscape(strings.Join(bulkIpgeoFlags.Fields, ",")) } if bulkIpgeoFlags.Language != "" { - url += "&lang=" + bulkIpgeoFlags.Language + url += "&lang=" + neturl.QueryEscape(bulkIpgeoFlags.Language) } payload := map[string]interface{}{ diff --git a/cmd/myIp.go b/cmd/myIp.go index 4e5befb..04051d6 100644 --- a/cmd/myIp.go +++ b/cmd/myIp.go @@ -3,8 +3,6 @@ package cmd import ( "encoding/json" "fmt" - "io" - "net/http" "github.com/IPGeolocation/cli/v2/internal/common" "github.com/IPGeolocation/cli/v2/internal/utils" @@ -37,19 +35,11 @@ Notes: - This command does not require an API key. `, Run: func(cmd *cobra.Command, args []string) { - resp, err := http.Get("https://api.ipgeolocation.io/getip") + body, err := utils.Get("https://api.ipgeolocation.io/getip") if err != nil { fmt.Println("Error fetching your IP address:", err) return } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != 200 { - fmt.Printf("Error: %s\n", string(body)) - return - } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { diff --git a/cmd/parseBulkUserAgents.go b/cmd/parseBulkUserAgents.go index 0696621..51f28a1 100644 --- a/cmd/parseBulkUserAgents.go +++ b/cmd/parseBulkUserAgents.go @@ -6,6 +6,7 @@ package cmd import ( "encoding/json" "fmt" + neturl "net/url" "github.com/IPGeolocation/cli/v2/internal/common" "github.com/IPGeolocation/cli/v2/internal/config" @@ -56,7 +57,7 @@ Note: } baseURL := "https://api.ipgeolocation.io/v3/user-agent-bulk" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) payload := map[string]interface{}{ "uaStrings": bulkUserAgentsFlags.UserAgents, diff --git a/cmd/release_test.go b/cmd/release_test.go new file mode 100644 index 0000000..99f9eb6 --- /dev/null +++ b/cmd/release_test.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestReleaseGuardsKeepExistingArtifacts(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("release assembly runs on Unix") + } + source, err := os.ReadFile("../build-release.sh") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ compiler, tag, want string }{ + {"go1.24.5", "", "Use Go"}, {"go1.26.9", "", "Use Go"}, {"go1.27.2", "", "Use Go"}, + {"go1.28rc1", "", "Use Go"}, {"go1.26.10", "v2.1.0", "Release tag must match"}, + {"go1.26.10", "v2.1.1", "checks-started"}, {"go1.27.3", "", "checks-started"}, {"go1.28.0", "", "checks-started"}, + } { + t.Run(tc.compiler+tc.tag, func(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "dist"), 0700); err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{ + "build-release.sh": string(source), "dist/keep": "existing archive", + "go": "#!/bin/sh\ncase \"$1\" in\nenv) echo \"$TEST_COMPILER\";;\nrun) echo 'ipgeolocation version 2.1.1';;\n*) echo checks-started >&2; exit 7;;\nesac\n", + "govulncheck": "#!/bin/sh\nexit 0\n", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0700); err != nil { + t.Fatal(err) + } + } + process := exec.Command("bash", filepath.Join(dir, "build-release.sh")) + process.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "TEST_COMPILER="+tc.compiler, "GITHUB_REF_TYPE=tag", "GITHUB_REF_NAME="+tc.tag) + if tc.tag == "" { + process.Env = append(process.Env, "GITHUB_REF_TYPE=branch") + } + output, err := process.CombinedOutput() + if err == nil || !strings.Contains(string(output), tc.want) { + t.Fatalf("unexpected guard result: %v\n%s", err, output) + } + data, err := os.ReadFile(filepath.Join(dir, "dist/keep")) + if err != nil || string(data) != "existing archive" { + t.Fatalf("guard destroyed existing artifacts: %q %v", data, err) + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index c06ce63..169a65c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,7 +17,7 @@ import ( var rootCmd = &cobra.Command{ Use: "ipgeolocation", Short: "CLI for accessing IPGeolocation.io API endpoints", - Version: "2.1.0", + Version: "2.1.1", Long: `ipgeolocation is a command-line interface (CLI) tool for accessing all major endpoints of the IPGeolocation.io API. It allows you to retrieve IP geolocation data, timezone information, currency details, user-agent parsing, and more, directly from your terminal. diff --git a/cmd/timeConversion.go b/cmd/timeConversion.go index cd830d5..8ec5cf6 100644 --- a/cmd/timeConversion.go +++ b/cmd/timeConversion.go @@ -3,15 +3,12 @@ package cmd import ( "encoding/json" "fmt" - "io" - "net/http" + neturl "net/url" "github.com/IPGeolocation/cli/v2/internal/common" "github.com/IPGeolocation/cli/v2/internal/config" "github.com/IPGeolocation/cli/v2/internal/utils" - encoding "net/url" - "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) @@ -60,22 +57,22 @@ Notes: } baseURL := "https://api.ipgeolocation.io/v3/timezone/convert" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) if timeConversionFlags.TimezoneFrom != "" { - url += "&tz_from=" + timeConversionFlags.TimezoneFrom + url += "&tz_from=" + neturl.QueryEscape(timeConversionFlags.TimezoneFrom) } if timeConversionFlags.TimezoneTo != "" { - url += "&tz_to=" + timeConversionFlags.TimezoneTo + url += "&tz_to=" + neturl.QueryEscape(timeConversionFlags.TimezoneTo) } if timeConversionFlags.LocationFrom != "" { - url += "&location_from=" + encoding.QueryEscape(timeConversionFlags.LocationFrom) + url += "&location_from=" + neturl.QueryEscape(timeConversionFlags.LocationFrom) } if timeConversionFlags.LocationTo != "" { - url += "&location_to=" + encoding.QueryEscape(timeConversionFlags.LocationTo) + url += "&location_to=" + neturl.QueryEscape(timeConversionFlags.LocationTo) } if timeConversionFlags.LatitudeFrom != 0 { @@ -95,46 +92,38 @@ Notes: } if timeConversionFlags.IataCodeFrom != "" { - url += "&iata_from=" + timeConversionFlags.IataCodeFrom + url += "&iata_from=" + neturl.QueryEscape(timeConversionFlags.IataCodeFrom) } if timeConversionFlags.IataCodeTo != "" { - url += "&iata_to=" + timeConversionFlags.IataCodeTo + url += "&iata_to=" + neturl.QueryEscape(timeConversionFlags.IataCodeTo) } if timeConversionFlags.IcaoCodeFrom != "" { - url += "&icao_from=" + timeConversionFlags.IcaoCodeFrom + url += "&icao_from=" + neturl.QueryEscape(timeConversionFlags.IcaoCodeFrom) } if timeConversionFlags.IcaoCodeTo != "" { - url += "&icao_to=" + timeConversionFlags.IcaoCodeTo + url += "&icao_to=" + neturl.QueryEscape(timeConversionFlags.IcaoCodeTo) } if timeConversionFlags.LoCodeFrom != "" { - url += "&locode_from=" + timeConversionFlags.LoCodeFrom + url += "&locode_from=" + neturl.QueryEscape(timeConversionFlags.LoCodeFrom) } if timeConversionFlags.LoCodeTo != "" { - url += "&locode_to=" + timeConversionFlags.LoCodeTo + url += "&locode_to=" + neturl.QueryEscape(timeConversionFlags.LoCodeTo) } if timeConversionFlags.Time != "" { - url += "&time=" + timeConversionFlags.Time + url += "&time=" + neturl.QueryEscape(timeConversionFlags.Time) } - resp, err := http.Get(url) + body, err := utils.Get(url) if err != nil { fmt.Println("Error fetching time info:", err) return } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != 200 { - fmt.Printf("Error: %s\n", string(body)) - return - } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { diff --git a/cmd/timezone.go b/cmd/timezone.go index 9798a1c..b44716f 100644 --- a/cmd/timezone.go +++ b/cmd/timezone.go @@ -3,15 +3,12 @@ package cmd import ( "encoding/json" "fmt" - "io" - "net/http" + neturl "net/url" "github.com/IPGeolocation/cli/v2/internal/common" "github.com/IPGeolocation/cli/v2/internal/config" "github.com/IPGeolocation/cli/v2/internal/utils" - encoding "net/url" - "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) @@ -62,33 +59,33 @@ Note: } baseURL := "https://api.ipgeolocation.io/v3/timezone" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) if timezoneFlags.IP != "" { - url += "&ip=" + timezoneFlags.IP + url += "&ip=" + neturl.QueryEscape(timezoneFlags.IP) } if timezoneFlags.IataCode != "" { - url += "&iata_code=" + timezoneFlags.IataCode + url += "&iata_code=" + neturl.QueryEscape(timezoneFlags.IataCode) } if timezoneFlags.IcaoCode != "" { - url += "&icao_code=" + timezoneFlags.IcaoCode + url += "&icao_code=" + neturl.QueryEscape(timezoneFlags.IcaoCode) } if timezoneFlags.LoCode != "" { - url += "&lo_code=" + timezoneFlags.LoCode + url += "&lo_code=" + neturl.QueryEscape(timezoneFlags.LoCode) } if timezoneFlags.Language != "" { - url += "&lang=" + timezoneFlags.Language + url += "&lang=" + neturl.QueryEscape(timezoneFlags.Language) } if timezoneFlags.Tz != "" { - url += "&tz=" + timezoneFlags.Tz + url += "&tz=" + neturl.QueryEscape(timezoneFlags.Tz) } if timezoneFlags.Location != "" { - url += "&location=" + encoding.QueryEscape(timezoneFlags.Location) + url += "&location=" + neturl.QueryEscape(timezoneFlags.Location) } if timezoneFlags.Latitude != 0 { @@ -99,19 +96,11 @@ Note: url += "&long=" + fmt.Sprintf("%f", timezoneFlags.Longitude) } - resp, err := http.Get(url) + body, err := utils.Get(url) if err != nil { fmt.Println("Error fetching timezone info:", err) return } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != 200 { - fmt.Printf("Error: %s\n", string(body)) - return - } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { diff --git a/cmd/userAgent.go b/cmd/userAgent.go index fffb440..794bf59 100644 --- a/cmd/userAgent.go +++ b/cmd/userAgent.go @@ -6,6 +6,7 @@ package cmd import ( "encoding/json" "fmt" + neturl "net/url" "github.com/IPGeolocation/cli/v2/internal/common" "github.com/IPGeolocation/cli/v2/internal/config" @@ -41,7 +42,7 @@ Examples: return } baseURL := "https://api.ipgeolocation.io/v3/user-agent" - url := baseURL + "?apiKey=" + cfg.ApiKey + url := baseURL + "?apiKey=" + neturl.QueryEscape(cfg.ApiKey) payload := map[string]interface{}{ "uaString": userAgentFlags.UserAgent, diff --git a/go.mod b/go.mod index f658312..440db79 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/IPGeolocation/cli/v2 -go 1.18 +go 1.26.10 require ( github.com/spf13/cobra v1.9.1 diff --git a/internal/config/config.go b/internal/config/config.go index 66e285f..1332753 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,20 +12,50 @@ type Config struct { ApiKey string `json:"apikey"` } -func configPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".ipgeolocation", "config.json") +func configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".ipgeolocation", "config.json"), nil } func Save(cfg Config) error { - path := configPath() - os.MkdirAll(filepath.Dir(path), 0755) - data, _ := json.MarshalIndent(cfg, "", " ") - return os.WriteFile(path, data, 0600) + path, err := configPath() + if err != nil { + return err + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + if err := os.Chmod(dir, 0700); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + file, err := os.CreateTemp(dir, ".config-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err := file.Write(data); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(file.Name(), path) } func Load() (Config, error) { - path := configPath() + path, err := configPath() + if err != nil { + return Config{}, err + } data, err := os.ReadFile(path) if err != nil { return Config{}, err diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5d98cbe --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,86 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/IPGeolocation/cli/v2/internal/utils" +) + +func testHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + return filepath.Join(home, ".ipgeolocation", "config.json") +} + +func TestSaveRestrictsExistingPermissions(t *testing.T) { + path := testHome(t) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"apikey":"old"}`), 0644); err != nil { + t.Fatal(err) + } + if err := Save(Config{ApiKey: "replacement"}); err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" { + for name, want := range map[string]os.FileMode{path: 0600, filepath.Dir(path): 0700} { + info, err := os.Stat(name) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != want { + t.Errorf("%s permissions = %o, want %o", name, info.Mode().Perm(), want) + } + } + } + cfg, err := Load() + if err != nil || cfg.ApiKey != "replacement" { + t.Fatalf("saved key not readable: %+v %v", cfg, err) + } +} + +func TestLoadLegacyKeys(t *testing.T) { + path := testHome(t) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + key := "legacy-test-key" + encrypted, err := utils.EncryptString(key) + if err != nil { + t.Fatal(err) + } + for _, stored := range []string{key, encrypted} { + data, err := json.Marshal(Config{ApiKey: stored}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load() + if err != nil || cfg.ApiKey != key { + t.Fatalf("legacy key not readable: %+v %v", cfg, err) + } + } +} + +func TestSaveReportsInvalidDirectory(t *testing.T) { + path := testHome(t) + if err := os.WriteFile(filepath.Dir(path), []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + if err := Save(Config{ApiKey: "test"}); err == nil { + t.Fatal("save accepted a non-directory") + } + data, err := os.ReadFile(filepath.Dir(path)) + if err != nil || string(data) != "keep" { + t.Fatalf("existing file changed: %q %v", data, err) + } +} diff --git a/internal/utils/request.go b/internal/utils/request.go index 416c93f..27dbe4e 100644 --- a/internal/utils/request.go +++ b/internal/utils/request.go @@ -6,20 +6,52 @@ import ( "fmt" "io" "net/http" + "net/url" + "strings" ) +func Get(apiURL string) ([]byte, error) { + return request(http.MethodGet, apiURL, nil, nil) +} + func PostJSON(apiURL string, payload interface{}, headers map[string]string) ([]byte, error) { data, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("failed to encode request body: %w", err) } - req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(data)) + return request(http.MethodPost, apiURL, bytes.NewReader(data), headers) +} + +type requestError struct { + cause error + message string +} + +func (e *requestError) Error() string { return e.message } +func (e *requestError) Unwrap() error { return e.cause } + +func request(method, apiURL string, data io.Reader, headers map[string]string) (body []byte, err error) { + req, err := http.NewRequest(method, apiURL, data) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return nil, fmt.Errorf("failed to create request: invalid URL") } - req.Header.Set("Content-Type", "application/json") + // API keys can appear in transport errors and in error responses from the server. + defer func() { + if key := req.URL.Query().Get("apiKey"); err != nil && key != "" { + encoded, _ := json.Marshal(key) + message := strings.NewReplacer( + key, "[REDACTED]", url.QueryEscape(key), "[REDACTED]", + url.PathEscape(key), "[REDACTED]", string(encoded[1:len(encoded)-1]), "[REDACTED]", + ).Replace(err.Error()) + err = &requestError{cause: err, message: message} + } + }() + + if method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } for k, v := range headers { req.Header.Set(k, v) } @@ -30,8 +62,11 @@ func PostJSON(apiURL string, payload interface{}, headers map[string]string) ([] } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { + body, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server error: %s", string(body)) } diff --git a/internal/utils/request_test.go b/internal/utils/request_test.go new file mode 100644 index 0000000..f233c91 --- /dev/null +++ b/internal/utils/request_test.go @@ -0,0 +1,101 @@ +package utils + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type brokenBody struct{ closed bool } + +func (*brokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } +func (b *brokenBody) Close() error { b.closed = true; return nil } + +func TestPostJSONDoesNotExposeAPIKey(t *testing.T) { + original := http.DefaultClient + t.Cleanup(func() { http.DefaultClient = original }) + http.DefaultClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, io.ErrUnexpectedEOF + })} + _, err := PostJSON("https://api.example.test/?apiKey=private-test-key", nil, nil) + if err == nil || strings.Contains(err.Error(), "private-test-key") { + t.Fatalf("request error exposes the key: %v", err) + } + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("request error lost its cause: %v", err) + } +} + +func TestPostJSONRejectsIncompleteResponse(t *testing.T) { + original := http.DefaultClient + t.Cleanup(func() { http.DefaultClient = original }) + body := &brokenBody{} + http.DefaultClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: body}, nil + })} + data, err := PostJSON("https://api.example.test/", nil, nil) + if !errors.Is(err, io.ErrUnexpectedEOF) || data != nil || !body.closed { + t.Fatalf("incomplete response accepted or body left open: data=%q err=%v closed=%v", data, err, body.closed) + } +} + +func TestPostJSONPreservesPayloadAndHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil || string(body) != `{"uaString":"quoted \"agent\""}` || r.Method != http.MethodPost || + r.Header.Get("Content-Type") != "application/json" || r.Header.Get("X-Test") != "value" { + t.Errorf("unexpected request: %s %s headers=%v body=%s err=%v", r.Method, r.URL, r.Header, body, err) + } + io.WriteString(w, `{"ok":true}`) + })) + defer server.Close() + body, err := PostJSON(server.URL, map[string]string{"uaString": `quoted "agent"`}, map[string]string{"X-Test": "value"}) + if err != nil || string(body) != `{"ok":true}` { + t.Fatalf("unexpected result: %q %v", body, err) + } +} + +func TestGetTLSVerification(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"ok":true}`) + })) + defer server.Close() + if _, err := Get(server.URL); err == nil { + t.Fatal("accepted an untrusted TLS certificate") + } + original := http.DefaultClient + t.Cleanup(func() { http.DefaultClient = original }) + http.DefaultClient = server.Client() + if body, err := Get(server.URL); err != nil || string(body) != `{"ok":true}` { + t.Fatalf("trusted TLS request failed: %s %v", body, err) + } +} + +func TestRequestErrorsRedactPlainAndEncodedKeys(t *testing.T) { + key := "private +&key" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + io.WriteString(w, key+" "+url.QueryEscape(key)+" "+url.PathEscape(key)) + json.NewEncoder(w).Encode(key) + })) + defer server.Close() + for _, call := range []func(string) ([]byte, error){Get, func(u string) ([]byte, error) { return PostJSON(u, nil, nil) }} { + _, err := call(server.URL + "?apiKey=" + url.QueryEscape(key)) + encoded, _ := json.Marshal(key) + if err == nil || !strings.Contains(err.Error(), "server error") || strings.Contains(err.Error(), key) || strings.Contains(err.Error(), url.QueryEscape(key)) || strings.Contains(err.Error(), url.PathEscape(key)) || strings.Contains(err.Error(), string(encoded[1:len(encoded)-1])) { + t.Fatalf("unsafe status error: %v", err) + } + if _, err := call("https://example.test/%invalid?apiKey=" + key); err == nil || strings.Contains(err.Error(), key) { + t.Fatalf("unsafe URL error: %v", err) + } + } +} diff --git a/postman/mocks/ipgeolocation/config.yaml b/postman/mocks/ipgeolocation/config.yaml new file mode 100644 index 0000000..2965be1 --- /dev/null +++ b/postman/mocks/ipgeolocation/config.yaml @@ -0,0 +1,7 @@ +version: 1 +name: ipgeolocation +origin: https://api.ipgeolocation.io +interceptRequests: true +scenarios: + - name: default + path: default.js diff --git a/postman/mocks/ipgeolocation/default.js b/postman/mocks/ipgeolocation/default.js new file mode 100644 index 0000000..48502f3 --- /dev/null +++ b/postman/mocks/ipgeolocation/default.js @@ -0,0 +1,32 @@ +const http = require('node:http'); + +const endpoints = new Set([ + '/getip', '/v3/ipgeo', '/v3/security', '/v3/abuse', '/v3/asn', '/v3/timezone', + '/v3/timezone/convert', '/v3/astronomy', '/v3/astronomy/timeSeries', + '/v3/ipgeo-bulk', '/v3/security-bulk', '/v3/user-agent', '/v3/user-agent-bulk', +]); + +http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + const reply = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); }; + if (!endpoints.has(url.pathname)) return reply(501, { message: 'Endpoint not modeled' }); + const isPost = url.pathname.endsWith('-bulk') || url.pathname === '/v3/user-agent'; + if (req.method !== (isPost ? 'POST' : 'GET')) return reply(400, { message: 'Unexpected HTTP method' }); + const key = url.searchParams.get('apiKey'); + const statuses = { BAD_KEY: 401, FORBIDDEN: 403, RATE_LIMIT: 429, SERVER_ERROR: 503 }; + if (statuses[key]) return reply(statuses[key], { message: `Test failure for ${key}` }); + if (key === 'BAD_JSON') { res.writeHead(200); return res.end('{'); } + if (url.pathname !== '/getip' && !['TEST_KEY', 'TEST_KEY &+?=#%'].includes(key)) return reply(401, { message: 'Invalid test key' }); + let payload = null; + if (isPost) { + if (req.headers['content-type'] !== 'application/json') return reply(400, { message: 'Expected JSON' }); + payload = req.body; + if (!payload || typeof payload !== 'object') return reply(400, { message: 'Invalid JSON payload' }); + } + const query = Object.fromEntries([...url.searchParams.keys()].map((name) => [name, url.searchParams.getAll(name)])); + const result = { + ip: '2001:db8::1', location: { city: 'Test City' }, + _request: { method: req.method, path: url.pathname, query, payload }, + }; + reply(200, url.pathname.endsWith('-bulk') ? [result] : result); +}).listen(process.env.PORT || 4500); diff --git a/postman/simulations/cli/simulation.context.yaml b/postman/simulations/cli/simulation.context.yaml new file mode 100644 index 0000000..16fb5aa --- /dev/null +++ b/postman/simulations/cli/simulation.context.yaml @@ -0,0 +1,30 @@ +purpose: + stands_in_for: The IPGeolocation endpoints used by the CLI, including bulk and user-agent POST requests. + unblocks: Command, query encoding, JSON payload, output format and error regression checks over HTTPS. +assumptions: + - id: command-compatibility + status: supported + claim: Commands preserve endpoint paths, decoded parameters, payloads and supported output formats. + reason: The fixture echoes the received request so command tests can check actual HTTPS traffic. + verification: On 2026-10-29, all 61 command and format cases plus 10 HTTP error requests passed. Proxy logs confirm 71 HTTPS requests across all 13 endpoints. Another 57 comparisons against the previous CLI matched, including bulk output files and encrypted-key compatibility in both directions. + source: cmd/commands_test.go; postman/mocks/ipgeolocation/default.js + updated_at: '2026-10-29' + - id: live-provider-contract + status: unknown + claim: The simulation establishes live service behavior or account permissions. + reason: No published mock was listed in the official IPGeolocation.io API Hub workspace; Orbit discovery returned HTTP 403. The fixture models the existing CLI contract. + verification: No live API key or provider requests are used. + source: cmd; postman/mocks/ipgeolocation/default.js + updated_at: '2026-10-29' + - id: proxy-certificate-serials + status: supported + claim: Postman CLI 1.73.0 can issue negative-serial test certificates. + reason: The simulation process alone enables the Go compatibility flag for these fixture certificates. Normal TLS tests and production executables keep strict defaults. + verification: Reproduced during the preceding Go integration checks with this installed CLI version. + source: scripts/test-http.cjs; internal/utils/request_test.go + updated_at: '2026-10-29' +fingerprints: + - id: cli-v2.1.1 + date: '2026-10-29' + commit: df51101 plus the CLI request and release changes in this branch. + cli: Postman CLI 1.73.0; Go 1.26.10 and 1.27.3. diff --git a/postman/simulations/cli/simulation.yaml b/postman/simulations/cli/simulation.yaml new file mode 100644 index 0000000..2ae6efc --- /dev/null +++ b/postman/simulations/cli/simulation.yaml @@ -0,0 +1,10 @@ +id: 8e1b3eef-dc59-4214-87bd-e7697e1affa0 +simulation: cli +routing: path +mocks: + - name: ipgeolocation + id: 51ed6b5a-fbde-4888-886f-8de8f4225503 + routeKey: ipgeolocation + origin: + - api.ipgeolocation.io + path: ../../mocks/ipgeolocation/config.yaml diff --git a/scripts/test-http.cjs b/scripts/test-http.cjs new file mode 100644 index 0000000..3a4f1a9 --- /dev/null +++ b/scripts/test-http.cjs @@ -0,0 +1,82 @@ +'use strict'; + +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); + +async function main() { + const proxy = spawn('postman', [ + 'simulation', 'run', 'postman/simulations/cli/simulation.yaml', + '--proxy', '--proxy-port', '7013', '--no-history', '--log-format', 'json', + ], { + cwd: path.resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }); + const closed = new Promise((resolve) => proxy.once('close', resolve)); + let output = ''; + let errors = ''; + let timer; + let binaryDirectory; + try { + const certificate = await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(`Postman proxy did not start: ${errors}`)), 20000); + proxy.once('error', reject); + proxy.once('exit', (code) => reject(new Error(`Postman proxy exited (${code}): ${errors}`))); + proxy.stderr.on('data', (chunk) => { errors += chunk; }); + proxy.stdout.on('data', (chunk) => { + output += chunk; + const match = output.match(/NODE_EXTRA_CA_CERTS=([^\r\n]+)/); + if (match) resolve(match[1].trim()); + }); + }); + clearTimeout(timer); + let binary; + // Go 1.26 on macOS uses platform trust; the test helper supplies the simulation CA there. + if (process.platform === 'linux') { + binaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ipgeolocation-cli-')); + binary = path.join(binaryDirectory, 'ipgeolocation'); + const build = spawn('go', ['build', '-o', binary, './cmd/ipgeolocation'], { + cwd: path.resolve(__dirname, '..'), stdio: 'inherit', + }); + const [code] = await once(build, 'exit'); + if (code !== 0) throw new Error('CLI build failed'); + } + const checks = spawn('go', ['test', './cmd', '-run', '^TestCLI(Commands|HTTPErrors)$', '-count=1', '-v'], { + cwd: path.resolve(__dirname, '..'), stdio: 'inherit', + env: { + ...process.env, + HTTPS_PROXY: 'http://127.0.0.1:7013', HTTP_PROXY: 'http://127.0.0.1:7013', + NO_PROXY: '', SSL_CERT_FILE: certificate, CLI_HTTP_TEST: '1', + CLI_TEST_BINARY: binary || '', + // shortcut: allow Postman 1.73's negative test-certificate serials until the CLI fixes them. + GODEBUG: [process.env.GODEBUG, 'x509negativeserial=1'].filter(Boolean).join(','), + }, + }); + const [code] = await once(checks, 'exit'); + process.exitCode = code === 0 ? 0 : 1; + console.log(output); + if (process.exitCode) console.error(errors); + } finally { + clearTimeout(timer); + if (binaryDirectory) fs.rmSync(binaryDirectory, { recursive: true, force: true }); + if (proxy.pid) { + // The npm-installed CLI launches a child; stop the entire proxy process group. + const stop = (signal) => { + try { + if (process.platform === 'win32') proxy.kill(signal); + else process.kill(-proxy.pid, signal); + } catch (err) { + if (err.code !== 'ESRCH') throw err; + } + }; + const force = setTimeout(() => stop('SIGKILL'), 2000); + stop('SIGINT'); + await closed; + clearTimeout(force); + } + } +} + +main().catch((err) => { console.error(err.message); process.exitCode = 1; });