diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..d9ab2bf --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,33 @@ +name: E2E + +permissions: + contents: read + +on: + schedule: + - cron: "0 8 * * *" + workflow_dispatch: + +env: + GOTOOLCHAIN: local + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up tools + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + with: + version: 2026.3.8 + + - name: Run E2E tests + run: make e2e + env: + QDRANT_CLOUD_API_KEY: ${{ secrets.E2E_QDRANT_CLOUD_API_KEY }} + QDRANT_CLOUD_ACCOUNT_ID: ${{ secrets.E2E_QDRANT_CLOUD_ACCOUNT_ID }} diff --git a/Makefile b/Makefile index 10cb87e..5a19a03 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo unknown) -.PHONY: build debug debug-run test lint format clean bootstrap docs docs-check +.PHONY: build debug debug-run test lint format clean bootstrap docs docs-check e2e build: CGO_ENABLED=0 go build -ldflags "-X main.version=$(VERSION)" -o build/qcloud ./cmd/qcloud @@ -32,6 +32,9 @@ bootstrap: { echo "mise is not installed. Install it from https://mise.jdx.dev/installing-mise.html"; exit 1; } mise install +e2e: + QCLOUD_E2E=1 go test -timeout 20m -v -count=1 ./test/e2e/ + docs: go run ./cmd/docgen ./docs/reference diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 0000000..29efa2d --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,90 @@ +# qcloud e2e tests + +End-to-end tests that run the real `qcloud` binary against a real Qdrant Cloud +backend. These tests **create paid resources** on whatever account you point +them at — never run them against a production account without first +double-checking the credentials. + +## Running + +``` +make e2e +``` + +Or directly: + +``` +QCLOUD_E2E=1 \ +QDRANT_CLOUD_API_KEY=... \ +QDRANT_CLOUD_ACCOUNT_ID=... \ +go test -timeout 30m -v -count=1 ./test/e2e/... +``` + +Without `QCLOUD_E2E=1` every test in this tree is skipped, so `go test ./...` +from the repo root stays network-free. + +## Environment variables + +| Variable | Required | Purpose | +| -------------------------- | -------- | ----------------------------------------------------------------------- | +| `QCLOUD_E2E` | yes | Enables the suite. Any non-empty value works. | +| `QDRANT_CLOUD_API_KEY` | yes | Management API key used by every invocation. | +| `QDRANT_CLOUD_ACCOUNT_ID` | yes | Account ID the test resources are created under. | +| `QDRANT_CLOUD_ENDPOINT` | no | Override the gRPC endpoint (defaults to production). | +| `QCLOUD_E2E_BINARY` | no | Absolute path to a pre-built `qcloud` binary. Skips the download. | +| `QCLOUD_E2E_RELEASE` | no | GitHub release tag to download (default `latest`). | + +## Binary acquisition + +The binary is resolved exactly once per `go test` invocation in `TestMain`: + +1. If `QCLOUD_E2E_BINARY` is set, it is used as-is. +2. Otherwise the `qcloud--.tar.gz` archive from the release given by + `QCLOUD_E2E_RELEASE` (default `latest`) is downloaded, its sha256 is checked + against the release's `checksums.txt`, and the binary is extracted into + `$XDG_CACHE_HOME/qcloud-e2e//qcloud` (or the platform equivalent). + +Cache entries are keyed by the archive's sha256, so repeated runs on the same +host reuse the same extracted binary — and automatically pick up a fresh copy +when the upstream release changes. + +To test a locally-built binary: + +``` +make build +QCLOUD_E2E_BINARY=$PWD/build/qcloud make e2e +``` + +## Writing new tests + +Every test starts with `framework.NewEnv(t)`; it returns an `*Env` that wraps +the binary path, an isolated empty config file, and generic CLI wrappers: + +- `env.Run(t, args...)` — runs `qcloud`, fails the test on non-zero exit, + streams stdout/stderr to `t.Log`. +- `env.RunAllowFail(t, args...)` — same, but returns the result instead of + failing. Use it for negative tests. +- `env.RunJSON(t, &v, args...)` — appends `--json` and decodes stdout into `v`. + +Resource-specific helpers live next to the tests that use them — they're +ordinary `_test.go` files in `package e2e_test`, free to grow without +bloating the framework. For clusters, see `cluster_helpers_test.go`: + +- `createCluster(t, env, opts)` — creates a cluster and registers cleanup. + The cluster name defaults to `e2e-`, which makes leak sweeps safe. +- `waitCluster(t, env, id, timeout)` — blocks until the cluster is healthy. +- `sweepLeakedClusters(t, env, maxAge)` — best-effort cleanup of stale + `e2e-*` clusters; call it from a dedicated test if you want automatic + housekeeping in CI. + +Prefer `RunJSON` over scraping human output — the JSON shape is stable, +human output is not. + +## Safety notes + +- Every cluster created through `createCluster` is scheduled for deletion via + `t.Cleanup`. If a runner is killed mid-test, orphans remain; use + `sweepLeakedClusters` to catch them. +- Tests are **not** safe to run with `t.Parallel()` today — they share a + single account and a small quota. Don't add `t.Parallel()` without also + isolating each test's account or region. diff --git a/test/e2e/cluster_helpers_test.go b/test/e2e/cluster_helpers_test.go new file mode 100644 index 0000000..b1ee90f --- /dev/null +++ b/test/e2e/cluster_helpers_test.go @@ -0,0 +1,136 @@ +package e2e_test + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/qdrant/qcloud-cli/test/e2e/framework" +) + +// e2eClusterPrefix is the name prefix used for every cluster created via +// createCluster. sweepLeakedClusters targets the same prefix. +const e2eClusterPrefix = "e2e-" + +// createClusterOpts captures the knobs tests commonly tweak when creating a +// cluster. Fields left at zero values pick sensible defaults. +type createClusterOpts struct { + // Name is the cluster name. If empty, a random "e2e-" name is used. + Name string + // CloudProvider defaults to "aws". + CloudProvider string + // CloudRegion defaults to "eu-central-1". + CloudRegion string + // Package defaults to "free2". + Package string +} + +// createCluster creates a cluster and registers a t.Cleanup that force-deletes +// it when the test finishes. The returned ID is the cluster's GUID. The +// cluster is not waited on; call waitCluster if readiness matters to the test. +func createCluster(t *testing.T, env *framework.Env, opts createClusterOpts) string { + t.Helper() + + if opts.Name == "" { + opts.Name = e2eClusterPrefix + randomSuffix(t) + } + if opts.CloudProvider == "" { + opts.CloudProvider = "aws" + } + if opts.CloudRegion == "" { + opts.CloudRegion = "eu-central-1" + } + if opts.Package == "" { + opts.Package = "free2" + } + + var cluster struct { + ID string `json:"id"` + } + env.RunJSON(t, &cluster, + "cluster", "create", + "--name", opts.Name, + "--cloud-provider", opts.CloudProvider, + "--cloud-region", opts.CloudRegion, + "--package", opts.Package, + ) + if cluster.ID == "" { + t.Fatalf("cluster create returned empty id") + } + + t.Logf("created cluster %s (%s)", cluster.ID, opts.Name) + t.Cleanup(func() { deleteCluster(t, env, cluster.ID) }) + return cluster.ID +} + +// waitCluster blocks until the cluster is healthy or the timeout elapses. +func waitCluster(t *testing.T, env *framework.Env, id string, timeout time.Duration) { + t.Helper() + env.RunWithTimeout(t, timeout+30*time.Second, + "cluster", "wait", id, "--timeout", timeout.String(), + ) +} + +// deleteCluster force-deletes a cluster, logging but not failing on error so +// it is safe to call from t.Cleanup. +func deleteCluster(t *testing.T, env *framework.Env, id string) { + t.Helper() + if id == "" { + return + } + res := env.RunAllowFail(t, "cluster", "delete", id, "--force") + if res.Err != nil { + t.Logf("cleanup: cluster delete %s failed: %v", id, res.Err) + } +} + +// sweepLeakedClusters lists clusters in the current account and force-deletes +// any whose name starts with e2eClusterPrefix and whose CreatedAt is older +// than maxAge. It never fails the test — leaks are a best-effort safety net. +func sweepLeakedClusters(t *testing.T, env *framework.Env, maxAge time.Duration) { + t.Helper() + + var resp struct { + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + } `json:"items"` + } + + res := env.RunAllowFail(t, "cluster", "list", "--json") + if res.Err != nil { + t.Logf("leak sweep: cluster list failed: %v", res.Err) + return + } + if err := json.Unmarshal([]byte(res.Stdout), &resp); err != nil { + t.Logf("leak sweep: decoding cluster list: %v", err) + return + } + + cutoff := time.Now().Add(-maxAge) + for _, c := range resp.Items { + if !strings.HasPrefix(c.Name, e2eClusterPrefix) { + continue + } + if !c.CreatedAt.IsZero() && c.CreatedAt.After(cutoff) { + continue + } + t.Logf("leak sweep: deleting stale cluster %s (%s, created %s)", + c.ID, c.Name, c.CreatedAt.Format(time.RFC3339)) + deleteCluster(t, env, c.ID) + } +} + +// randomSuffix returns 8 hex chars for disambiguating parallel test runs. +func randomSuffix(t *testing.T) string { + t.Helper() + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + t.Fatalf("rand: %v", err) + } + return hex.EncodeToString(b[:]) +} diff --git a/test/e2e/cluster_test.go b/test/e2e/cluster_test.go new file mode 100644 index 0000000..909f6fa --- /dev/null +++ b/test/e2e/cluster_test.go @@ -0,0 +1,31 @@ +package e2e_test + +import ( + "testing" + "time" + + "github.com/qdrant/qcloud-cli/test/e2e/framework" +) + +// TestClusterLifecycle exercises the happy path of cluster create → wait → +// key create → delete against a real backend. +func TestClusterLifecycle(t *testing.T) { + env := framework.NewEnv(t) + + // Best-effort cleanup of clusters orphaned by killed previous runs. + sweepLeakedClusters(t, env, time.Hour) + + id := createCluster(t, env, createClusterOpts{ + CloudProvider: "aws", + CloudRegion: "eu-central-1", + Package: "free2", + }) + + waitCluster(t, env, id, 15*time.Minute) + + env.Run(t, + "cluster", "key", "create", id, + "--name", "e2e-test-key", + "--wait", + ) +} diff --git a/test/e2e/framework/binary.go b/test/e2e/framework/binary.go new file mode 100644 index 0000000..ae62c1c --- /dev/null +++ b/test/e2e/framework/binary.go @@ -0,0 +1,221 @@ +// Package framework provides shared helpers for the qcloud e2e test suite. +package framework + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" +) + +const ( + envBinary = "QCLOUD_E2E_BINARY" + envRelease = "QCLOUD_E2E_RELEASE" + + releaseBaseURL = "https://github.com/qdrant/qcloud-cli/releases" + defaultRelease = "latest" + + downloadTimeout = 2 * time.Minute +) + +var ( + binaryOnce sync.Once + binaryPath string + binaryErr error +) + +// Binary returns the path to the qcloud binary to exercise. The binary is +// resolved exactly once per process: +// +// 1. $QCLOUD_E2E_BINARY, if set. +// 2. A cached download of the release named by $QCLOUD_E2E_RELEASE (default +// "latest"), verified against the release's checksums.txt. +// +// Downloads are cached under os.UserCacheDir()/qcloud-e2e keyed by the archive +// sha256, so repeated test runs on the same host reuse the same binary. +func Binary() (string, error) { + binaryOnce.Do(func() { + binaryPath, binaryErr = resolveBinary() + }) + return binaryPath, binaryErr +} + +func resolveBinary() (string, error) { + if p := os.Getenv(envBinary); p != "" { + if _, err := os.Stat(p); err != nil { + return "", fmt.Errorf("%s=%s: %w", envBinary, p, err) + } + return p, nil + } + + release := os.Getenv(envRelease) + if release == "" { + release = defaultRelease + } + + archive := fmt.Sprintf("qcloud-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) + base := fmt.Sprintf("%s/%s/download", releaseBaseURL, release) + + expectedSHA, err := fetchExpectedSHA(base+"/checksums.txt", archive) + if err != nil { + return "", err + } + + cacheDir, err := binaryCacheDir(expectedSHA) + if err != nil { + return "", err + } + + binary := filepath.Join(cacheDir, "qcloud") + if _, err := os.Stat(binary); err == nil { + return binary, nil + } + + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", fmt.Errorf("creating cache dir: %w", err) + } + + tmp, err := downloadVerified(base+"/"+archive, expectedSHA) + if err != nil { + return "", err + } + defer func() { _ = os.Remove(tmp) }() + + if err := extractQcloud(tmp, binary); err != nil { + return "", err + } + if err := os.Chmod(binary, 0o755); err != nil { + return "", fmt.Errorf("chmod binary: %w", err) + } + return binary, nil +} + +func binaryCacheDir(sha string) (string, error) { + root, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("resolving user cache dir: %w", err) + } + return filepath.Join(root, "qcloud-e2e", sha[:16]), nil +} + +// fetchExpectedSHA downloads checksums.txt and returns the sha256 for the +// given archive file name. The file is generated by goreleaser and looks like: +// +// a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 qcloud-linux-amd64.tar.gz +// f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5 qcloud-linux-arm64.tar.gz +// 9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b qcloud-darwin-arm64.tar.gz +func fetchExpectedSHA(url, archive string) (string, error) { + body, err := httpGetBody(url) + if err != nil { + return "", err + } + defer func() { _ = body.Close() }() + + scan := bufio.NewScanner(body) + for scan.Scan() { + fields := strings.Fields(scan.Text()) + if len(fields) != 2 { + continue + } + if fields[1] == archive { + return fields[0], nil + } + } + if err := scan.Err(); err != nil { + return "", fmt.Errorf("reading checksums: %w", err) + } + return "", fmt.Errorf("%s not listed in %s", archive, url) +} + +// downloadVerified writes the URL body to a temp file and verifies the +// sha256 against expected. Returns the temp file path on success. +func downloadVerified(url, expected string) (string, error) { + body, err := httpGetBody(url) + if err != nil { + return "", err + } + defer func() { _ = body.Close() }() + + tmp, err := os.CreateTemp("", "qcloud-e2e-*.tar.gz") + if err != nil { + return "", fmt.Errorf("creating temp archive: %w", err) + } + defer func() { _ = tmp.Close() }() + + h := sha256.New() + if _, err := io.Copy(io.MultiWriter(tmp, h), body); err != nil { + _ = os.Remove(tmp.Name()) + return "", fmt.Errorf("downloading %s: %w", url, err) + } + + got := hex.EncodeToString(h.Sum(nil)) + if got != expected { + _ = os.Remove(tmp.Name()) + return "", fmt.Errorf("checksum mismatch for %s: got %s, want %s", url, got, expected) + } + return tmp.Name(), nil +} + +func httpGetBody(url string) (io.ReadCloser, error) { + client := &http.Client{Timeout: downloadTimeout} + resp, err := client.Get(url) //nolint:noctx // bounded by client timeout + if err != nil { + return nil, fmt.Errorf("GET %s: %w", url, err) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("GET %s: %s", url, resp.Status) + } + return resp.Body, nil +} + +// extractQcloud pulls the "qcloud" binary out of a gzip-compressed tar at +// src and writes it to dst. +func extractQcloud(src, dst string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + gr, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("gzip reader: %w", err) + } + defer func() { _ = gr.Close() }() + + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return fmt.Errorf("qcloud binary not found in %s", src) + } + if err != nil { + return fmt.Errorf("tar reader: %w", err) + } + if filepath.Base(hdr.Name) != "qcloud" { + continue + } + + out, err := os.Create(dst) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + _ = out.Close() + return err + } + return out.Close() + } +} diff --git a/test/e2e/framework/env.go b/test/e2e/framework/env.go new file mode 100644 index 0000000..6e7e51c --- /dev/null +++ b/test/e2e/framework/env.go @@ -0,0 +1,216 @@ +package framework + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + "testing" + "time" +) + +const ( + envEnable = "QCLOUD_E2E" + envAPIKey = "QDRANT_CLOUD_API_KEY" + envAccountID = "QDRANT_CLOUD_ACCOUNT_ID" + envEndpoint = "QDRANT_CLOUD_ENDPOINT" + envConfig = "QDRANT_CLOUD_CONFIG" + + defaultRunTimeout = 2 * time.Minute +) + +// Env bundles everything an e2e test needs to shell out to qcloud against a +// real Qdrant Cloud backend. +type Env struct { + BinaryPath string + APIKey string + AccountID string + Endpoint string + + configPath string +} + +// NewEnv returns an Env wired up with credentials from the environment and an +// isolated empty config file. It skips the test if QCLOUD_E2E is not set. +// +// The binary is the one returned by Binary; it is resolved once per process. +func NewEnv(t *testing.T) *Env { + t.Helper() + + if os.Getenv(envEnable) == "" { + t.Skipf("set %s=1 to run e2e tests", envEnable) + } + + apiKey := mustEnv(t, envAPIKey) + accountID := mustEnv(t, envAccountID) + + bin, err := Binary() + if err != nil { + t.Fatalf("resolving qcloud binary: %v", err) + } + + configPath := writeEmptyConfig(t) + + return &Env{ + BinaryPath: bin, + APIKey: apiKey, + AccountID: accountID, + Endpoint: os.Getenv(envEndpoint), + configPath: configPath, + } +} + +func mustEnv(t *testing.T, name string) string { + t.Helper() + v := os.Getenv(name) + if v == "" { + t.Fatalf("%s must be set", name) + } + return v +} + +// writeEmptyConfig creates an empty config.yaml in t.TempDir() so the CLI +// doesn't try to read ~/.config/qcloud/config.yaml. +func writeEmptyConfig(t *testing.T) string { + t.Helper() + path := t.TempDir() + "/config.yaml" + if err := os.WriteFile(path, []byte{}, 0o600); err != nil { + t.Fatalf("writing empty config: %v", err) + } + return path +} + +// RunResult holds the outcome of a single binary invocation. +type RunResult struct { + Stdout string + Stderr string + ExitCode int + Err error +} + +// Run invokes the binary with the given args and the default timeout, failing +// the test if the command exits non-zero. Stdout and stderr are streamed to +// t.Log. +func (e *Env) Run(t *testing.T, args ...string) RunResult { + t.Helper() + return e.RunWithTimeout(t, defaultRunTimeout, args...) +} + +// RunWithTimeout is Run with a caller-supplied timeout. +func (e *Env) RunWithTimeout(t *testing.T, timeout time.Duration, args ...string) RunResult { + t.Helper() + res := e.runRaw(t, timeout, args) + if res.Err != nil { + t.Fatalf("qcloud %s failed: %v", strings.Join(args, " "), res.Err) + } + return res +} + +// RunAllowFail is like Run but returns the result without failing the test. +// Useful for commands where a non-zero exit is part of the assertion. +func (e *Env) RunAllowFail(t *testing.T, args ...string) RunResult { + t.Helper() + return e.runRaw(t, defaultRunTimeout, args) +} + +// RunJSON appends --json to args, runs the binary, and decodes stdout into v. +func (e *Env) RunJSON(t *testing.T, v any, args ...string) { + t.Helper() + res := e.Run(t, append(args, "--json")...) + if err := json.Unmarshal([]byte(res.Stdout), v); err != nil { + t.Fatalf("decoding JSON output of %q: %v\nstdout: %s", + strings.Join(args, " "), err, res.Stdout) + } +} + +func (e *Env) runRaw(t *testing.T, timeout time.Duration, args []string) RunResult { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, e.BinaryPath, args...) + cmd.Env = e.commandEnv() + + var stdout, stderr bytes.Buffer + cmd.Stdout = io.MultiWriter(&stdout, testLogWriter{t: t, prefix: "stdout: ", redact: e.APIKey}) + cmd.Stderr = io.MultiWriter(&stderr, testLogWriter{t: t, prefix: "stderr: ", redact: e.APIKey}) + + t.Logf("exec: qcloud %s", strings.Join(args, " ")) + + err := cmd.Run() + exitCode := -1 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + return RunResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + Err: err, + } +} + +func (e *Env) commandEnv() []string { + env := []string{ + fmt.Sprintf("%s=%s", envAPIKey, e.APIKey), + fmt.Sprintf("%s=%s", envAccountID, e.AccountID), + fmt.Sprintf("%s=%s", envConfig, e.configPath), + "HOME=" + e.homeDir(), + "PATH=" + os.Getenv("PATH"), + } + if e.Endpoint != "" { + env = append(env, fmt.Sprintf("%s=%s", envEndpoint, e.Endpoint)) + } + return env +} + +// homeDir points at the directory containing the empty config file so any +// code that falls through to $HOME still lands inside the test sandbox. +func (e *Env) homeDir() string { + if i := strings.LastIndex(e.configPath, "/"); i > 0 { + return e.configPath[:i] + } + return os.TempDir() +} + +// Redact masks the API key and JWT-like tokens in s. Useful when asserting +// against stdout/stderr. +func (e *Env) Redact(s string) string { + if e.APIKey != "" { + s = strings.ReplaceAll(s, e.APIKey, "[REDACTED]") + } + return jwtLikeRe.ReplaceAllString(s, "[REDACTED]") +} + +// jwtLikeRe matches strings that look like JWTs: three base64url segments +// separated by dots, where the first two segments decode to JSON objects. +// We anchor on word boundaries so we don't clip larger tokens. +var jwtLikeRe = regexp.MustCompile(`\b[A-Za-z0-9_-]{4,}\.` + + `[A-Za-z0-9_-]{4,}\.` + + `[A-Za-z0-9_-]{4,}\b`) + +// testLogWriter forwards writes to t.Log, splitting on newlines and redacting +// secrets so they never end up in CI output. +type testLogWriter struct { + t *testing.T + prefix string + redact string +} + +func (w testLogWriter) Write(p []byte) (int, error) { + s := string(p) + if w.redact != "" { + s = strings.ReplaceAll(s, w.redact, "[REDACTED]") + } + s = jwtLikeRe.ReplaceAllString(s, "[REDACTED]") + for line := range strings.SplitSeq(strings.TrimRight(s, "\n"), "\n") { + w.t.Log(w.prefix + line) + } + return len(p), nil +} diff --git a/test/e2e/framework/redact_test.go b/test/e2e/framework/redact_test.go new file mode 100644 index 0000000..51f33d4 --- /dev/null +++ b/test/e2e/framework/redact_test.go @@ -0,0 +1,46 @@ +package framework + +import "testing" + +func TestJWTLikeRedaction(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "jwt token", + input: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123_signature-value", + want: "[REDACTED]", + }, + { + name: "jwt embedded in text", + input: "Your key: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123_signature-value here", + want: "Your key: [REDACTED] here", + }, + { + name: "no jwt", + input: "just a normal string", + want: "just a normal string", + }, + { + name: "dotted but too short segments", + input: "a.b.c", + want: "a.b.c", + }, + { + name: "version-like string not matched", + input: "v1.2.3", + want: "v1.2.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := jwtLikeRe.ReplaceAllString(tt.input, "[REDACTED]") + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go new file mode 100644 index 0000000..28a502d --- /dev/null +++ b/test/e2e/main_test.go @@ -0,0 +1,24 @@ +package e2e_test + +import ( + "log" + "os" + "testing" + + "github.com/qdrant/qcloud-cli/test/e2e/framework" +) + +// TestMain pre-resolves the qcloud binary once before any test runs. Without +// this, every test that calls framework.NewEnv would otherwise trigger a +// fresh download attempt on its first invocation. +// +// When QCLOUD_E2E is unset we skip setup entirely so `go test ./...` stays +// free of network calls. +func TestMain(m *testing.M) { + if os.Getenv("QCLOUD_E2E") != "" { + if _, err := framework.Binary(); err != nil { + log.Fatalf("e2e: resolving qcloud binary: %v", err) + } + } + os.Exit(m.Run()) +}