From 522cad3ba6fbd4a8bed92b1e3f86a543f99ca86b Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Fri, 26 Jun 2026 12:06:13 +0200 Subject: [PATCH] test: add end-to-end suite driving the cac binary against a real tenant Adds a build-tagged (//go:build e2e) suite under e2e/ that runs the compiled cac binary as a subprocess against the postmance-dev test tenant: - TestImportAndPull: create a workspace via `push --method import`, pull it back - TestImportRoundTrip: change the workspace name, re-import, pull into a fresh dir, and assert the change persisted (the contract assertion a mock can't make) Tenant constraints handled: workspaces are created via import (the client lacks server-admin rights), mutations use import (the rfc7396 patch endpoint is forbidden for this client), and runCAC retries the config API's burst rate-limit (request_forbidden) with backoff. Tests run sequentially. Credentials come from CAC_E2E_* env vars (gitignored .env.local locally, secrets in CI); .env.local.example documents them. CI runs on merge_group via .github/workflows/e2e.yaml. Env parsing uses subosito/gotenv (already in the module graph via viper). Workspace deletion is not yet implemented; runs leak e2e-* workspaces. --- .env.local.example | 10 + .github/workflows/e2e.yaml | 28 ++ .gitignore | 1 + Makefile | 4 + .../specs/2026-06-25-cac-e2e-tests-design.md | 226 ++++++++++++++++ e2e/e2e_test.go | 127 +++++++++ e2e/harness.go | 249 ++++++++++++++++++ go.mod | 2 +- 8 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 .env.local.example create mode 100644 .github/workflows/e2e.yaml create mode 100644 docs/superpowers/specs/2026-06-25-cac-e2e-tests-design.md create mode 100644 e2e/e2e_test.go create mode 100644 e2e/harness.go diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..8419dd1 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,10 @@ +# Credentials for the e2e suite (go test -tags e2e ./e2e/...). +# Copy to .env.local (gitignored) and fill in the secret. The suite reads these +# CAC_E2E_* vars; CI provides them as secrets instead. +# +# Values below target the postmance-dev test tenant (region eu). +CAC_E2E_ISSUER_URL=https://postmance-dev.eu.authz.cloudentity.io/postmance-dev/system +CAC_E2E_TENANT_ID=postmance-dev +CAC_E2E_CLIENT_ID=21ac20db6d0c4b8e8772f82af0a741c2 +CAC_E2E_CLIENT_SECRET= +# CAC_E2E_INSECURE=true # only for self-signed/local servers diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..7ca8a79 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,28 @@ +name: e2e + +# Runs the real-server end-to-end suite against the test tenant. Triggered by +# the merge queue so it executes once on the rebased to-be-merged HEAD and +# blocks the merge if it fails — not on every PR commit. Also runnable manually. +on: + merge_group: + workflow_dispatch: + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-latest + environment: e2e # holds the test-tenant secrets + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: run e2e suite + run: go test -tags e2e -timeout 20m -v ./e2e/... + env: + CAC_E2E_ISSUER_URL: ${{ secrets.CAC_E2E_ISSUER_URL }} + CAC_E2E_TENANT_ID: ${{ secrets.CAC_E2E_TENANT_ID }} + CAC_E2E_CLIENT_ID: ${{ secrets.CAC_E2E_CLIENT_ID }} + CAC_E2E_CLIENT_SECRET: ${{ secrets.CAC_E2E_CLIENT_SECRET }} diff --git a/.gitignore b/.gitignore index 51964d8..4047ca7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .idea /cac examples/e2e-local/ +.env.local diff --git a/Makefile b/Makefile index 1b63898..38c99db 100644 --- a/Makefile +++ b/Makefile @@ -15,5 +15,9 @@ install: test: go test ./internal/... +.PHONY: test-e2e +test-e2e: + go test -tags e2e -timeout 20m -v ./e2e/... + .PHONY: all all: build lint test install \ No newline at end of file diff --git a/docs/superpowers/specs/2026-06-25-cac-e2e-tests-design.md b/docs/superpowers/specs/2026-06-25-cac-e2e-tests-design.md new file mode 100644 index 0000000..955b0db --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-cac-e2e-tests-design.md @@ -0,0 +1,226 @@ +# cac End-to-End Test Suite — Design + +Date: 2026-06-25 +Status: Proposed (awaiting review) + +## Problem + +`cac` is a CLI that syncs SecureAuth configuration between local files and a +SecureAuth server (`pull`, `push`, `diff`). The existing test suite is +package-level and uses a hand-written `httptest` mock of the SecureAuth API. + +The bugs that actually hurt are at the **tool ↔ API boundary**: contract drift, +payload-shape mismatches, validation rules the mock doesn't reproduce, and +`acp-client-go`-version-vs-deployed-API skew. A hand-written mock cannot catch +these by construction — it encodes our assumptions, so when an assumption is +wrong the mock is wrong the same way the tool is, and the test stays green. + +This suite closes that gap with **real-server end-to-end tests** that exercise +the compiled binary against a live SecureAuth test tenant. + +## Decisions + +| Decision | Choice | +|---|---| +| Test level | Compiled binary driven as a subprocess (`os/exec`); assert on exit code, stdout/stderr, and on-disk + remote state | +| Backend | Real SecureAuth test tenant (no mock) | +| Layering | Real-server suite only; existing package/unit tests continue to cover tool logic. No hermetic e2e layer. | +| CI trigger | GitHub **merge queue** (`merge_group`) — runs once on the rebased to-be-merged HEAD, blocks merge if red; does **not** run on every PR commit | +| Isolation | **Ephemeral workspace per run** — created via `cac push --method import` to a unique id (see Implementation notes; the admin-API create/delete approach was dropped). Deletion deferred. | +| Target tenant | Existing dedicated test tenant `postmance-dev`, region `eu`. Issuer derived as `https://postmance-dev.eu.authz.cloudentity.io/postmance-dev/system` | +| Credentials | Confirmed available: a system-level client in `postmance-dev` can be granted create/delete-authorization-server + `manage_configuration` rights | +| Scope (v1) | **Trimmed to a single core round-trip test** plus a pull smoke; remaining flows deferred (see Test matrix) | + +## Architecture + +``` +e2e/ (new, build-tagged //go:build e2e) + main_test.go TestMain: load env, build binary once, stale-workspace sweep + harness.go runCAC(), temp-config writer, ephemeral-workspace lifecycle + pull_test.go pull flows + push_test.go push patch / import / mode / dry-run flows + diff_test.go diff flows + errors_test.go credential / flag / missing-resource failures + tenant_test.go tenant-mode read smoke (read-only; no tenant writes) +``` + +- A `//go:build e2e` tag keeps these out of `go test ./internal/...` (the unit + suite stays fast and offline). They run only via `go test -tags e2e ./e2e/...`. +- **`TestMain`** does once-per-run setup: read credentials from env, `go build` + the binary to a temp path, and sweep leftover `e2e-*` workspaces older than a + threshold (defends against workspaces leaked by a crashed prior run). It skips + the whole suite (not fails) when required env vars are absent, so the tag plus + missing creds is a clean no-op locally. +- **Ephemeral workspace lifecycle** uses `acp-client-go`'s + `admin.Servers.CreateAuthorizationServer` / `DeleteAuthorizationServer` + (already a transitive dependency). Each test creates its own workspace named + `e2e-` and registers deletion via `t.Cleanup`, so tests are + parallel-safe (`t.Parallel()`) and a failed assertion still tears down. +- **`runCAC(t, args...)`** writes a temp `config.yaml` whose `client.issuer_url` + points at the real tenant (with the test client id/secret) and whose + `storage.dir_path` is a per-test temp dir, then runs the binary with the given + args and returns `{stdout, stderr, exitCode}`. + +## Data flow (the core round-trip test) + +``` +create ephemeral ws ──► cac pull ──► files in temp storage dir + (edit a file: add a client) + cac push --method patch ──► server state mutated + cac pull (fresh dir) ──► files reflect the edit + assert: client present remotely +teardown: delete ephemeral ws +``` + +This round-trip is the assertion that the mock can never make: it proves the +binary's serialized payload is one the real API accepts *and* round-trips. + +## Test matrix + +### v1 — ship this + +Workspace mode, each in its own ephemeral workspace: + +1. **pull smoke** — create a workspace, `cac pull`; assert exit 0 and that the + expected resource files are written to the storage dir. Proves auth + + export + file writing against the real API. +2. **push patch round-trip** *(core contract test)* — `pull`, add a client to a + local file, `push --method patch`, re-pull into a fresh dir, assert the + client is present remotely. This is the one assertion the mock can never + make; it is the reason the suite exists. + +That is the whole v1. The single round-trip already transitively exercises +workspace create, OAuth client-credentials, export, import/patch, export again, +and teardown — maximum contract coverage for minimum surface. + +### Deferred (add later, one at a time, only if a real bug motivates it) + +push import round-trip · diff local-vs-remote (`--no-volatile`) · `--filter` · +`--with-secrets` · `--dry-run` non-mutation · `--mode fail`/`ignore` · +CLI error cases · tenant-mode read smoke. + +Tenant-mode **writes** stay permanently out of scope (too destructive to e2e). + +## Assertions & flakiness mitigations + +- The server injects volatile fields (timestamps, generated IDs) and normalizes + payloads. Assert on **semantic presence** ("client X exists", "field == Y"), + not byte-for-byte file equality. Use the tool's own `--no-volatile` for diff + assertions. +- One ephemeral workspace per test → isolation + `t.Parallel()` safe. +- `t.Cleanup` deletes the workspace on pass or fail; `TestMain` sweeps stale + `e2e-*` workspaces at suite start as a backstop for crashed runs. + +## Hardening (high-level, pre-implementation) + +These are the failure modes and edge cases the design must survive. They are +design constraints, not implementation steps. + +- **Issuer construction.** Derive the issuer from tenant + region: + `https://postmance-dev.eu.authz.cloudentity.io/postmance-dev/system`. The + admin/system client authenticates client-credentials against this `/system` + issuer; the same base is written into each per-test `config.yaml` (with + `storage.dir_path` pointed at a temp dir, `logging.level: debug` for CI logs). +- **Setup ordering in `TestMain`.** (1) read env, skip suite if any required var + is missing; (2) `go build` the binary once to a temp path; (3) init the admin + client; (4) sweep stale `e2e-*` workspaces (see below); (5) run tests. A + failure in 2–4 aborts the suite with a clear message rather than masquerading + as per-test failures. +- **Workspace identifier constraints.** The ephemeral name must be a valid + authorization-server id (lowercase alphanumeric/dash, bounded length). Use + `e2e--` so names are globally unique even when + **multiple PRs run concurrently in the merge queue** — name collisions and + the tenant's max-workspace quota are the two concurrency hazards; unique names + plus reliable teardown address both. +- **Teardown reliability.** Deletion is registered with `t.Cleanup` so it runs + on pass, failure, or assertion panic. If deletion itself fails, log loudly but + do not fail an otherwise-green test — the stale-workspace sweep is the + backstop. The sweep at suite start deletes any `e2e-*` workspace older than a + threshold (e.g. 2h), cleaning up after crashed or killed prior runs. +- **Eventual consistency.** Treat export-after-write as possibly lagging: + wrap the "re-pull and assert" step in a short bounded retry/poll rather than a + single immediate read, so a momentarily-stale export doesn't flake the suite. +- **Determinism.** Assert semantic presence, never byte-equality; the server + normalizes payloads and injects volatile fields. Use `--no-volatile` wherever + a diff is asserted. +- **Secret hygiene.** Credentials come only from CI secrets / a gated + `environment`; never commit a real tenant config. The committed + `examples/e2e/config.yaml` keeps its invalid placeholder secrets. + +## CI integration + +New workflow `.github/workflows/e2e.yaml`: + +```yaml +on: + merge_group: # rebased to-be-merged HEAD, before merge + workflow_dispatch: # manual on-demand / debugging +jobs: + e2e: + runs-on: ubuntu-latest + environment: e2e # holds the test-tenant secrets + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + - run: go build -o /tmp/cac . + - run: go test -tags e2e -timeout 20m ./e2e/... + env: + CAC_E2E_ISSUER_URL: ${{ secrets.CAC_E2E_ISSUER_URL }} + CAC_E2E_TENANT_ID: ${{ secrets.CAC_E2E_TENANT_ID }} + CAC_E2E_CLIENT_ID: ${{ secrets.CAC_E2E_CLIENT_ID }} + CAC_E2E_CLIENT_SECRET: ${{ secrets.CAC_E2E_CLIENT_SECRET }} +``` + +- Repo settings: enable **merge queue** for `master` and mark the `e2e` job a + **required status check** in branch protection. Result: the suite runs exactly + once per PR, on the real code that will land, and a red run ejects the PR from + the queue without merging. +- Secrets are available to `merge_group` runs (they execute in the base-repo + context), which is precisely why merge queue fits — fork-PR `pull_request` + runs would not have them. +- The test client needs scopes to create/delete authorization servers and to + export/import workspace configuration in the test tenant + (`manage_configuration` plus server-admin rights). + +## Resolved + +1. **Credentials/scope** — confirmed: a system-level client in `postmance-dev` + can be granted create/delete-authorization-server + `manage_configuration`. +2. **Test tenant** — exists: `postmance-dev`, region `eu`. No provisioning work. +3. **v1 scope** — trimmed to pull smoke + push-patch round-trip (above). + +## Out of scope + +- Hermetic/mock e2e layer (explicitly excluded; unit tests cover tool logic). +- Tenant-level writes (`--tenant push/import`). +- Performance/load testing. + +## Implementation notes (as built, verified live against `postmance-dev`) + +The original design assumed the test client could create/delete authorization +servers via the admin API. Live verification changed several things: + +- **Workspace creation via `cac push --method import`, not the admin API.** The + client is *not* granted server-admin rights, so `CreateAuthorizationServer` + returned 403. Import to a fresh workspace id creates it. The harness no longer + uses `acp-client-go` directly — it only drives the binary. +- **Deletion deferred.** No teardown yet; each run leaks `e2e-*` workspaces. A + cleanup mechanism is to be designed separately. The created ids are logged. +- **Round-trip mutates via `import`, not `patch`.** The rfc7396 patch endpoint + (`promote/config-rfc7396`) is consistently `request_forbidden` for this + client (a permission gap), while `import` is allowed. The round-trip creates a + workspace, re-imports it with a changed `name`, pulls into a fresh dir, and + asserts the new name persisted. +- **Tests run sequentially (no `t.Parallel`) + rate-limit backoff.** The config + API rejects bursts of operations with `request_forbidden` (which + `acp-client-go` does not retry). `runCAC` retries that signal with backoff + (0/15/30/45s); a verification pull needed up to the 45s attempt in practice. + Consider raising the e2e client's rate limit to make runs faster/cheaper. +- **Files:** `e2e/harness.go`, `e2e/e2e_test.go` (`//go:build e2e`), + `.github/workflows/e2e.yaml`, `.env.local.example`, `Makefile` (`test-e2e`). + Tests: `TestImportAndPull` (smoke), `TestImportRoundTrip` (core contract). + +### Follow-ups +- Workspace deletion / cleanup of leaked `e2e-*` workspaces (owner: deferred). +- Raise/whitelist the e2e client's config-API rate limit to speed up runs. +- Add deferred flows as motivated: diff, filter, with-secrets, dry-run, errors. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..25347b1 --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,127 @@ +//go:build e2e + +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "testing" +) + +// Package-level fixtures set up once by TestMain. +var ( + testBin string // path to the compiled cac binary + testCreds creds // credentials for the test tenant +) + +func TestMain(m *testing.M) { + loadDotEnv() + + c, ok := loadCreds() + if !ok { + fmt.Printf("skipping e2e suite: set %s, %s, %s and %s (e.g. in a gitignored .env.local) to run\n", + envIssuer, envTenant, envClientID, envSecret) + os.Exit(0) + } + testCreds = c + + bin, cleanup, err := buildBinary() + if err != nil { + fmt.Printf("failed to build cac binary: %v\n", err) + os.Exit(1) + } + defer cleanup() + testBin = bin + + os.Exit(m.Run()) +} + +// buildBinary compiles the cac binary once for the whole suite. +func buildBinary() (bin string, cleanup func(), err error) { + dir, err := os.MkdirTemp("", "cac-e2e-bin") + if err != nil { + return "", nil, err + } + + bin = filepath.Join(dir, "cac") + + cmd := exec.Command("go", "build", "-o", bin, ".") + cmd.Dir = ".." // repo root, where main.go lives + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err = cmd.Run(); err != nil { + _ = os.RemoveAll(dir) + return "", nil, err + } + + return bin, func() { _ = os.RemoveAll(dir) }, nil +} + +// nameMatches reports whether the server config has a top-level name field equal +// to want. +func nameMatches(config []byte, want string) bool { + return regexp.MustCompile(`(?m)^name: ` + regexp.QuoteMeta(want) + `\s*$`).Match(config) +} + +// Tests run sequentially (no t.Parallel): the SecureAuth config API rejects +// concurrent import/patch operations from the same client with a transient +// "request_forbidden". + +// TestImportAndPull creates a workspace via `push --method import` and then +// pulls it back, asserting the binary authenticates, imports, exports, and +// writes the workspace's server file with the expected name. +func TestImportAndPull(t *testing.T) { + ws, _, _ := createWorkspace(t, testCreds) + + cfg, storage := writeConfig(t, testCreds) + if res := runCAC(t, "--config", cfg, "--workspace", ws, "pull"); res.ExitCode != 0 { + t.Fatalf("pull exited %d, want 0", res.ExitCode) + } + + got, err := os.ReadFile(serverFile(storage, ws)) + if err != nil { + t.Fatalf("expected server file for workspace %q: %v", ws, err) + } + + if !nameMatches(got, ws) { + t.Fatalf("workspace name %q not found in pulled config:\n%s", ws, got) + } +} + +// TestImportRoundTrip is the core contract test: create a workspace, change a +// field locally, push it, then pull into a fresh directory and assert the +// change survived the round-trip through the real API. This is the assertion a +// mock backend cannot make. +// +// It uses --method import for the update because this client is not permitted to +// use the rfc7396 patch endpoint. +func TestImportRoundTrip(t *testing.T) { + ws, configPath, storageDir := createWorkspace(t, testCreds) + + // Change the workspace display name locally and re-import it. + newName := "e2e-renamed-" + randSuffix(t) + seedServerFile(t, storageDir, ws, newName) + + if res := runCAC(t, "--config", configPath, "--workspace", ws, "push", "--method", "import"); res.ExitCode != 0 { + t.Fatalf("update import exited %d, want 0", res.ExitCode) + } + + // Pull into a fresh storage dir and assert the change persisted remotely. + verifyConfig, verifyStorage := writeConfig(t, testCreds) + if res := runCAC(t, "--config", verifyConfig, "--workspace", ws, "pull"); res.ExitCode != 0 { + t.Fatalf("verification pull exited %d, want 0", res.ExitCode) + } + + got, err := os.ReadFile(serverFile(verifyStorage, ws)) + if err != nil { + t.Fatalf("read verification server file: %v", err) + } + + if !nameMatches(got, newName) { + t.Fatalf("round-trip name %q not found in remote config:\n%s", newName, got) + } +} diff --git a/e2e/harness.go b/e2e/harness.go new file mode 100644 index 0000000..7ac5ac9 --- /dev/null +++ b/e2e/harness.go @@ -0,0 +1,249 @@ +//go:build e2e + +// Package e2e contains end-to-end tests that drive the compiled cac binary as a +// subprocess against a real SecureAuth tenant. +// +// Each test provisions its own ephemeral workspace by importing a minimal +// configuration to a fresh, uniquely-named workspace id (`cac push --method +// import` creates the workspace if it does not exist). Workspace deletion is +// not yet implemented, so runs leak workspaces named `e2e-*`; the created ids +// are logged for later cleanup. +// +// The suite only runs under the `e2e` build tag and only when the required +// CAC_E2E_* credentials are present; otherwise it skips cleanly. See +// docs/superpowers/specs/2026-06-25-cac-e2e-tests-design.md. +package e2e + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/subosito/gotenv" +) + +// creds holds the credentials for the test tenant, sourced from CAC_E2E_* env +// vars (optionally loaded from a gitignored .env.local at the repo root). +type creds struct { + IssuerURL string + TenantID string + ClientID string + ClientSecret string + Insecure bool +} + +const ( + envIssuer = "CAC_E2E_ISSUER_URL" + envTenant = "CAC_E2E_TENANT_ID" + envClientID = "CAC_E2E_CLIENT_ID" + envSecret = "CAC_E2E_CLIENT_SECRET" + envInsecure = "CAC_E2E_INSECURE" +) + +// loadCreds reads credentials from the environment. ok is false when any +// required variable is missing, signalling the suite to skip. +func loadCreds() (creds, bool) { + c := creds{ + IssuerURL: os.Getenv(envIssuer), + TenantID: os.Getenv(envTenant), + ClientID: os.Getenv(envClientID), + ClientSecret: os.Getenv(envSecret), + Insecure: os.Getenv(envInsecure) == "true", + } + + if c.IssuerURL == "" || c.TenantID == "" || c.ClientID == "" || c.ClientSecret == "" { + return creds{}, false + } + + return c, true +} + +// loadDotEnv best-effort loads a .env.local file (searched in the current dir +// and the repo root one level up) into the process environment. gotenv.Load +// does not override variables already set, so CI's real env vars take +// precedence while developers can keep secrets in a gitignored file. gotenv is +// already in the module graph (a transitive dependency of viper). +func loadDotEnv() { + for _, path := range []string{".env.local", filepath.Join("..", ".env.local")} { + if _, err := os.Stat(path); err != nil { + continue + } + + _ = gotenv.Load(path) + return + } +} + +// randSuffix returns a short random hex string for unique resource names. +func randSuffix(t *testing.T) string { + t.Helper() + + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + t.Fatalf("rand: %v", err) + } + + return hex.EncodeToString(b) +} + +// newWorkspaceID returns a globally-unique ephemeral workspace id. GITHUB_RUN_ID +// disambiguates separate CI runs (e.g. several PRs in the merge queue); it is +// empty locally. Tests within a run execute sequentially. +func newWorkspaceID(t *testing.T) string { + t.Helper() + + run := os.Getenv("GITHUB_RUN_ID") + if run != "" { + run += "-" + } + + return strings.ToLower(fmt.Sprintf("e2e-%s%s", run, randSuffix(t))) +} + +// createWorkspace provisions a fresh ephemeral workspace by importing a minimal +// configuration to a new id, and returns the workspace id plus a config path +// and storage dir already pointed at it. Deletion is not yet implemented, so +// the workspace leaks; its id is logged for later cleanup. +func createWorkspace(t *testing.T, c creds) (ws, configPath, storageDir string) { + t.Helper() + + ws = newWorkspaceID(t) + configPath, storageDir = writeConfig(t, c) + + seedServerFile(t, storageDir, ws, ws) + + if res := runCAC(t, "--config", configPath, "--workspace", ws, "push", "--method", "import"); res.ExitCode != 0 { + t.Fatalf("import (create workspace %q) exited %d, want 0", ws, res.ExitCode) + } + + t.Logf("created ephemeral workspace %q (NOT auto-deleted)", ws) + + return ws, configPath, storageDir +} + +// seedServerFile writes a minimal server.yaml for a workspace so push/import has +// something to send. +func seedServerFile(t *testing.T, storageDir, workspace, name string) { + t.Helper() + + path := serverFile(storageDir, workspace) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir seed: %v", err) + } + + content := fmt.Sprintf("name: %s\n", name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write seed server file: %v", err) + } +} + +// serverFile is the path to a workspace's server file within a storage dir. +func serverFile(storageDir, workspace string) string { + return filepath.Join(storageDir, "workspaces", workspace, "server.yaml") +} + +// writeConfig writes a cac config.yaml pointing at the test tenant with a fresh +// per-call storage dir, and returns the config path and storage dir. +func writeConfig(t *testing.T, c creds) (configPath, storageDir string) { + t.Helper() + + dir := t.TempDir() + storageDir = filepath.Join(dir, "data") + configPath = filepath.Join(dir, "config.yaml") + + cfg := fmt.Sprintf(`logging: + level: debug + format: text +client: + issuer_url: %q + client_id: %q + client_secret: %q + tenant_id: %q + insecure: %t +storage: + dir_path: %q +`, c.IssuerURL, c.ClientID, c.ClientSecret, c.TenantID, c.Insecure, storageDir) + + if err := os.WriteFile(configPath, []byte(cfg), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + return configPath, storageDir +} + +// cacResult captures the outcome of a cac invocation. +type cacResult struct { + Stdout string + Stderr string + ExitCode int +} + +// backoffSchedule is the wait before each retry attempt. The SecureAuth config +// API rate-limits bursts of operations and signals it with "request_forbidden" +// (which acp-client-go does not retry); a short cooldown clears it. +var backoffSchedule = []time.Duration{0, 15 * time.Second, 30 * time.Second, 45 * time.Second} + +// runCAC runs the compiled binary with the given args and returns its output +// and exit code, retrying transient rate-limit failures with backoff. It +// deliberately passes a minimal environment so the binary reads its config from +// the file, not from leaked CLIENT_*/STORAGE_* env vars (which cac picks up via +// viper). Retrying is safe for the operations the suite runs: pull is +// read-only and import replaces the whole config idempotently. +func runCAC(t *testing.T, args ...string) cacResult { + t.Helper() + + var res cacResult + + for attempt, wait := range backoffSchedule { + if wait > 0 { + t.Logf("cac %v rate-limited, retrying in %s (attempt %d)", args, wait, attempt+1) + time.Sleep(wait) + } + + res = runCACOnce(t, args...) + if res.ExitCode == 0 || !isRateLimited(res.Stderr) { + break + } + } + + return res +} + +// isRateLimited reports whether stderr carries the API's burst rate-limit signal. +func isRateLimited(stderr string) bool { + return strings.Contains(stderr, "request_forbidden") +} + +func runCACOnce(t *testing.T, args ...string) cacResult { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, testBin, args...) + cmd.Env = []string{"HOME=" + os.Getenv("HOME"), "PATH=" + os.Getenv("PATH")} + + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + res := cacResult{Stdout: stdout.String(), Stderr: stderr.String()} + if exitErr, ok := err.(*exec.ExitError); ok { + res.ExitCode = exitErr.ExitCode() + } else if err != nil { + t.Fatalf("running cac %v: %v", args, err) + } + + t.Logf("cac %v -> exit %d\nstdout:\n%s\nstderr:\n%s", args, res.ExitCode, res.Stdout, res.Stderr) + + return res +} diff --git a/go.mod b/go.mod index 4fa3370..6c6d53a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.11.1 + github.com/subosito/gotenv v1.6.0 golang.org/x/exp v0.0.0-20240119083558-1b970713d09a ) @@ -70,7 +71,6 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/subosito/gotenv v1.6.0 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect