From 7c361ef6990bb50171817ac2f0ff7ad6eee8d8a6 Mon Sep 17 00:00:00 2001 From: Duane May Date: Mon, 24 Aug 2026 14:15:03 -0400 Subject: [PATCH 1/6] Add live-UAA end-to-end integration test suite uaa-cli's existing tests all mock the UAA HTTP responses, so a real server's response shapes are never exercised -- which is exactly how `uaa list-clients` broke against a real server (a go-uaa unmarshal bug in cloudfoundry-community/ go-uaa#allowpublic-string-value). Add a Ginkgo suite (build-tag gated, so `make test` is untouched) that boots a real UAA and drives the compiled uaa binary through client/user/group/token/misc commands, plus a GitHub Actions workflow to run it on every PR. Also documents further coverage gaps found while auditing this (missing fields, and a UAA feature go-uaa still models after its removal) for later triage in go-uaa-coverage-gaps.md. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/integration-test.yml | 68 ++++++ Makefile | 4 + go-uaa-coverage-gaps.md | 106 +++++++++ integration/uaa_test.go | 293 +++++++++++++++++++++++++ scripts/start-uaa.sh | 53 +++++ 5 files changed, 524 insertions(+) create mode 100644 .github/workflows/integration-test.yml create mode 100644 go-uaa-coverage-gaps.md create mode 100644 integration/uaa_test.go create mode 100755 scripts/start-uaa.sh diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..31927d3 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,68 @@ +name: UAA integration test + +# Exercises the compiled uaa-cli binary against a real UAA server (not a +# mocked HTTP server) so that response-shape bugs -- e.g. go-uaa failing to +# unmarshal a field UAA legitimately serializes as more than one JSON type -- +# are caught before merge instead of by a user in production. +# +# Known gaps (not covered here): get-authcode-token and get-implicit-token +# need a real browser + local OAuth redirect callback, which isn't reliably +# scriptable headlessly. Zone-scoped (-z/--zone) command variants are also +# not exercised. + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: '20 7 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + uaa-integration-test: + runs-on: ubuntu-latest + steps: + - name: Checkout uaa-cli + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Checkout cloudfoundry/uaa + uses: actions/checkout@v7 + with: + repository: cloudfoundry/uaa + ref: develop + path: uaa + persist-credentials: false + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '25' + + - name: Cache Gradle + uses: actions/cache@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('uaa/**/*.gradle*', 'uaa/gradle/wrapper/gradle-wrapper.properties') }} + + - name: Start UAA + run: scripts/start-uaa.sh uaa + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + check-latest: true + + - name: Run live UAA integration suite + run: make test-integration + + - name: Dump UAA boot log on failure + if: failure() + run: cat uaa-boot.log || true diff --git a/Makefile b/Makefile index 03e7a9a..f18d850 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,10 @@ format: ## Format Go Code test: ## Run Ginkgo tests go run github.com/onsi/ginkgo/v2/ginkgo -v -r --randomize-suites --randomize-all -race +.PHONY: test-integration +test-integration: ## Run the live-UAA integration suite (requires a running UAA at $$UAA_TARGET, default http://localhost:8080/uaa) + go run github.com/onsi/ginkgo/v2/ginkgo -tags integration -v ./integration/... + .PHONY: goreleaser-check goreleaser-check: ## Test goreleaser configuration goreleaser check diff --git a/go-uaa-coverage-gaps.md b/go-uaa-coverage-gaps.md new file mode 100644 index 0000000..9c5609e --- /dev/null +++ b/go-uaa-coverage-gaps.md @@ -0,0 +1,106 @@ +# go-uaa / UAA coverage gaps + +Notes from auditing `github.com/cloudfoundry-community/go-uaa` (client library +used by uaa-cli) against the current UAA server source, triggered by +`uaa list-clients` breaking on `allowpublic` being serialized as a JSON +string instead of a boolean. Two distinct bug classes were found. Fixing +these is not yet scheduled — capturing findings here for future triage. + +## Bug class 1: strict-typed fields on loosely-typed UAA data (fixed) + +`Client` is the only UAA resource whose config is stored in a generic +`Map additionalInformation` and flattened into JSON via +`@JsonAnyGetter`/`@JsonAnySetter` with zero type coercion — so a field's JSON +type depends on how/when the client was created, not a fixed schema. Fixed +in go-uaa branch `fix/allowpublic-string-value` (2 commits) by converting +these to `FooRaw interface{}` + `Foo()` accessor pairs: + +- `AllowPublic` (the original bug) +- `ApprovalsDeleted`, `LastModified`, `AllowedProviders`, `RequiredUserGroups` + +`CreatedWith`/`TokenSalt` go through the same untyped path but are always +used as plain strings server-side, so left as-is. + +The new `.github/workflows/integration-test.yml` / `integration/uaa_test.go` +suite will fail against uaa-cli's currently-pinned go-uaa v0.4.2 until this +fix is released and go.mod is bumped — `scripts/boot/uaa.yml` (the default +profile the CI job boots) seeds `login`, `client_federated_jwt_trust`, +`client_with_allowpublic_and_jwks_uri_trust`, and +`oauth_showcase_saml2_bearer` with `allowpublic`/`autoapprove` as literal +YAML strings, which reproduces the exact original bug. That failure is +expected and is the point of the new CI job. + +## Bug class 2: fields/resources UAA has that go-uaa doesn't model at all + +Unlike bug class 1, these are silent gaps, not crashes — Go's +`json.Unmarshal` ignores unknown fields, so uaa-cli simply can't read, set, +or expose them. + +### IdentityProvider — no support at all (biggest gap) + +go-uaa has **zero** `IdentityProvider` type. UAA has a full resource here +(`/Users/mduane/Projects/uaa/model/src/main/java/org/cloudfoundry/identity/uaa/provider/`) +with LDAP/SAML/OIDC/Keystone/UAA-internal config classes, including real +CA-cert/TLS trust fields: +- SAML (`SamlIdentityProviderDefinition`) & OIDC/OAuth + (`AbstractExternalOAuthIdentityProviderDefinition`): `skipSslValidation` + (bool), `caCertificates` (`List`) +- LDAP (`LdapIdentityProviderDefinition`): `tlsConfiguration` + (none/simple/ldaps), `caCertificates` + +This means no `create-idp`/`get-idp`/`update-idp`/`list-idps` in uaa-cli, and +no way to configure or inspect TLS trust for federated auth at all. + +### Client — additional gaps beyond bug class 1 + +- `refreshTokenUnique` (`ClientConstants.REFRESH_TOKEN_UNIQUE`) — per-client + override of concurrent-session/refresh-token uniqueness. Security-relevant, + actively read in `UaaTokenServices`. +- `jwt_creds` (`ClientJwtConfiguration.JWT_CREDS`) — client JWT-bearer + federation credentials, validated in `ClientAdminEndpointsValidator`. + Security-relevant. +- `signup_redirect_url` / `change_email_redirect_url` — post-signup/ + post-email-change redirect targets. Open-redirect surface if unvalidated. +- (`client_jwt_config` is intentionally stripped server-side before GET + responses — not a real gap; go-uaa's `ChangeClientJWT` already covers the + write path.) + +### IdentityZone — several gaps, ranked + +- `issuer` — per-zone custom token issuer override. **Security-critical**: + affects JWT `iss` validation (`KeyInfoService`, `UaaTokenUtils`). Entirely + missing. +- `defaultIdentityProvider` — default IdP routing per zone + (`IdentityZoneConfigurationBootstrap`). +- `UserConfig`: go-uaa only has `defaultGroups`; missing `allowedGroups`, + `maxUsers`, `checkOriginEnabled`, `allowOriginLoop`. +- `SamlConfig.entityID` — custom per-zone SAML SP entity ID. +- `BrandingInformation`: go-uaa has 3 of 8 fields; missing + `footerLegalText`, `footerLinks`, `banner`, `consent`, `loginConsent` + (mostly cosmetic; `consent`/`loginConsent` have mild compliance relevance). + +### User — real but lower-priority gaps + +- `aliasZid` / `aliasId` — cross-zone shadow-user/alias federation + (`ScimUser.patch`). Worth prioritizing over the rest of this list. +- `salt` — password hashing salt (has a plain getter/setter, not + `@JsonIgnore`). +- Cosmetic SCIM attributes: `displayName`, `nickName`, `profileUrl`, `title`, + `userType`, `preferredLanguage`, `locale`, `timezone`. +- (`passwordLastModified`, `previousLogonTime`, `lastLogonTime` already + present and correct. No MFA/TOTP or `passwordChangeRequired` fields exist + on `ScimUser` itself.) + +### Group — complete, no gaps found. + +## Bombshell: MfaProvider is dead code + +UAA removed the entire MFA-provider feature server-side in February 2024 +(migration `V4_106__Drop_MFA_Tables.sql` drops `mfa_providers` and +`user_google_mfa_credentials`; no `MfaProvider`/`GoogleMfaProviderConfig` +Java class exists anywhere in the current `develop` branch — only a stray +`AuditEventType` enum remnant). go-uaa's `MFAProvider`/`MFAProviderConfig` +types and `/mfa-providers` endpoint model a resource that **no longer exists +on current UAA**. Any code relying on this is pointing at a dead endpoint. +Worth confirming what, if anything, in uaa-cli actually surfaces this before +deciding whether to remove it from go-uaa. diff --git a/integration/uaa_test.go b/integration/uaa_test.go new file mode 100644 index 0000000..ab925d3 --- /dev/null +++ b/integration/uaa_test.go @@ -0,0 +1,293 @@ +//go:build integration + +// Package integration exercises the compiled uaa-cli binary against a real, +// already-running UAA server (see scripts/start-uaa.sh) instead of a mocked +// HTTP server. It exists to catch bugs that only manifest against real +// server responses -- e.g. https://github.com/cloudfoundry-community/go-uaa +// unmarshalling a field UAA legitimately serializes as more than one JSON +// type -- which a mocked-response unit test can't surface because the mock +// only ever returns what the test author expects. +package integration + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gexec" +) + +func TestIntegration(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Live UAA Integration Suite") +} + +var ( + commandPath string + homeDir string + uaaTarget string +) + +var _ = BeforeSuite(func() { + var err error + commandPath, err = Build("code.cloudfoundry.org/uaa-cli") + Expect(err).NotTo(HaveOccurred()) + + homeDir, err = os.MkdirTemp("", "uaa-cli-integration") + Expect(err).NotTo(HaveOccurred()) + Expect(os.Setenv("HOME", homeDir)).To(Succeed()) + + uaaTarget = os.Getenv("UAA_TARGET") + if uaaTarget == "" { + uaaTarget = "http://localhost:8080/uaa" + } + + waitForUAA(uaaTarget) +}) + +var _ = AfterSuite(func() { + os.RemoveAll(homeDir) + CleanupBuildArtifacts() +}) + +func waitForUAA(target string) { + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get(target + "/login") + if err == nil { + resp.Body.Close() + if resp.StatusCode < 500 { + return + } + } + time.Sleep(2 * time.Second) + } + Fail(fmt.Sprintf("UAA at %s never became ready", target)) +} + +// run executes the compiled uaa binary and waits for it to exit, without +// asserting on the outcome -- for cleanup steps where a failure shouldn't +// fail the test itself. +func run(args ...string) *Session { + cmd := exec.Command(commandPath, args...) + session, err := Start(cmd, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + Eventually(session, 30).Should(Exit()) + return session +} + +// runOK executes the compiled uaa binary and asserts it succeeded and didn't +// hit the class of unmarshal/response-parsing failure this suite guards +// against. +func runOK(args ...string) *Session { + session := run(args...) + stderr := string(session.Err.Contents()) + Expect(stderr).NotTo(ContainSubstring("cannot unmarshal"), "command %v produced an unmarshal error", args) + Expect(stderr).NotTo(ContainSubstring("unknown error"), "command %v produced an unexpected error", args) + Expect(session.ExitCode()).To(Equal(0), "command %v failed with stderr: %s", args, stderr) + return session +} + +func assertValidJSON(session *Session) { + var v interface{} + Expect(json.Unmarshal(session.Out.Contents(), &v)).To(Succeed(), "expected valid JSON, got: %s", string(session.Out.Contents())) +} + +var _ = Describe("uaa-cli against a live UAA server", Ordered, func() { + It("targets the server", func() { + runOK("target", uaaTarget, "-k") + }) + + It("gets an admin client-credentials token", func() { + runOK("get-client-credentials-token", "admin", "-s", "adminsecret") + }) + + It("shows server info", func() { + assertValidJSON(runOK("info")) + }) + + It("shows the current context", func() { + runOK("context") + }) + + It("lists saved contexts", func() { + runOK("contexts") + }) + + It("prints the version", func() { + runOK("version") + }) + + Describe("clients", func() { + clientID := "uaa-cli-integration-test-client" + + AfterAll(func() { + run("get-client-credentials-token", "admin", "-s", "adminsecret") + run("delete-client", clientID) + }) + + It("lists clients, including the legacy string-typed additionalInformation clients seeded by scripts/boot/uaa.yml", func() { + // login, client_federated_jwt_trust, client_with_allowpublic_and_jwks_uri_trust, + // and oauth_showcase_saml2_bearer are all seeded with allowpublic/autoapprove as + // YAML strings, which UAA stores verbatim and serializes back as JSON strings + // rather than booleans -- the exact shape that broke `uaa list-clients` originally. + session := runOK("list-clients") + assertValidJSON(session) + Expect(string(session.Out.Contents())).To(ContainSubstring("client_federated_jwt_trust")) + }) + + It("gets one of the legacy string-typed clients directly", func() { + assertValidJSON(runOK("get-client", "client_federated_jwt_trust")) + }) + + It("creates a client", func() { + runOK("create-client", clientID, + "-s", "test-secret", + "--authorized_grant_types", "client_credentials,refresh_token", + "--scope", "uaa.none", + "--authorities", "uaa.none", + ) + }) + + It("gets the new client", func() { + assertValidJSON(runOK("get-client", clientID)) + }) + + It("updates the client", func() { + runOK("update-client", clientID, "--scope", "uaa.none,openid") + }) + + It("sets the client secret", func() { + runOK("set-client-secret", clientID, "-s", "second-test-secret") + }) + + It("gets a token as the client and changes its own secret", func() { + runOK("get-client-credentials-token", clientID, "-s", "second-test-secret") + runOK("change-client-secret", "--old_secret", "second-test-secret", "--secret", "third-test-secret") + }) + }) + + Describe("users", func() { + username := "uaa-cli-integration-test-user" + + BeforeAll(func() { + runOK("get-client-credentials-token", "admin", "-s", "adminsecret") + }) + + AfterAll(func() { + run("delete-user", username) + }) + + It("creates a user", func() { + runOK("create-user", username, + "--givenName", "Integration", + "--familyName", "Test", + "--email", username+"@example.com", + "-p", "S0meSecur3Pass!", + ) + }) + + It("gets the user", func() { + assertValidJSON(runOK("get-user", username)) + }) + + It("lists users with a filter", func() { + assertValidJSON(runOK("list-users", "--filter", fmt.Sprintf(`userName eq %q`, username))) + }) + + It("updates the user", func() { + runOK("update-user", username, "--givenName", "Updated") + }) + + It("deactivates and reactivates the user", func() { + runOK("deactivate-user", username) + runOK("activate-user", username) + }) + + It("unlocks the user", func() { + runOK("unlock-user", username) + }) + }) + + Describe("groups", func() { + groupName := "uaa.cli.integration.test.group" + memberUsername := "uaa-cli-integration-test-group-member" + + BeforeAll(func() { + runOK("get-client-credentials-token", "admin", "-s", "adminsecret") + runOK("create-user", memberUsername, + "--givenName", "Group", "--familyName", "Member", + "--email", memberUsername+"@example.com", "-p", "S0meSecur3Pass!", + ) + }) + + AfterAll(func() { + run("delete-user", memberUsername) + }) + + It("creates a group", func() { + runOK("create-group", groupName, "-d", "uaa-cli integration test group") + }) + + It("gets the group", func() { + assertValidJSON(runOK("get-group", groupName)) + }) + + It("lists groups", func() { + assertValidJSON(runOK("list-groups")) + }) + + It("adds and removes a member", func() { + runOK("add-member", groupName, memberUsername) + runOK("remove-member", groupName, memberUsername) + }) + + It("maps and unmaps an external group", func() { + runOK("map-group", "cn=integration-test,ou=groups,dc=example,dc=com", groupName) + assertValidJSON(runOK("list-group-mappings")) + runOK("unmap-group", "cn=integration-test,ou=groups,dc=example,dc=com", groupName) + }) + }) + + Describe("tokens", func() { + BeforeEach(func() { + runOK("get-password-token", "cf", "-s", "", "-u", "marissa", "-p", "koala") + }) + + AfterEach(func() { + run("get-client-credentials-token", "admin", "-s", "adminsecret") + }) + + It("decodes the current access token", func() { + token := string(runOK("context", "--access_token").Out.Contents()) + runOK("decode-token", token) + }) + + It("gets the token signing key(s)", func() { + runOK("get-token-key") + runOK("get-token-keys") + }) + + It("refreshes the token", func() { + runOK("refresh-token", "-s", "") + }) + }) + + Describe("misc", func() { + It("curls an arbitrary endpoint", func() { + assertValidJSON(runOK("curl", "/info")) + }) + + It("gets userinfo for a user-context token", func() { + runOK("get-password-token", "cf", "-s", "", "-u", "marissa", "-p", "koala") + assertValidJSON(runOK("userinfo")) + runOK("get-client-credentials-token", "admin", "-s", "adminsecret") + }) + }) +}) diff --git a/scripts/start-uaa.sh b/scripts/start-uaa.sh new file mode 100755 index 0000000..365ffab --- /dev/null +++ b/scripts/start-uaa.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Builds and launches a real UAA server in the background using the default +# (hsqldb, no external DB/Docker) profile documented in the UAA README, then +# waits until it's ready to serve requests. +# +# Usage: scripts/start-uaa.sh [path-to-uaa-checkout] +# path-to-uaa-checkout defaults to ./uaa (matches the checkout path used by +# .github/workflows/integration-test.yml) +set -eu -o pipefail + +UAA_DIR="${1:-./uaa}" +READY_URL="http://localhost:8080/uaa/login" +TIMEOUT_SECONDS=300 + +if [ ! -d "${UAA_DIR}" ]; then + echo "UAA checkout not found at ${UAA_DIR}" >&2 + exit 1 +fi + +echo "Building UAA bootWar from ${UAA_DIR}..." +(cd "${UAA_DIR}" && ./gradlew bootWar) + +WAR_FILE="$(find "${UAA_DIR}" -name 'cloudfoundry-identity-uaa-*.war' -path '*/build/libs/*' | head -1)" +if [ -z "${WAR_FILE}" ]; then + echo "Could not find a built UAA war file under ${UAA_DIR}" >&2 + exit 1 +fi + +echo "Starting UAA from ${WAR_FILE}..." +nohup java \ + -DCLOUDFOUNDRY_CONFIG_PATH="${UAA_DIR}/scripts/boot" \ + -DSECRETS_DIR="${UAA_DIR}/scripts/boot" \ + -Dserver.servlet.context-path=/uaa \ + -Dsmtp.host=localhost \ + -Dsmtp.port=2525 \ + -Dspring.profiles.active=hsqldb \ + -Djava.security.egd=file:/dev/./urandom \ + -jar "${WAR_FILE}" > uaa-boot.log 2>&1 & + +echo "Waiting up to ${TIMEOUT_SECONDS}s for UAA to respond at ${READY_URL}..." +elapsed=0 +while [ "${elapsed}" -lt "${TIMEOUT_SECONDS}" ]; do + if curl --silent --fail --output /dev/null "${READY_URL}"; then + echo "UAA is up." + exit 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) +done + +echo "UAA failed to start within ${TIMEOUT_SECONDS}s. Boot log:" >&2 +cat uaa-boot.log >&2 +exit 1 From 0ef3456fcb880f160c19789008f25c96ea283505 Mon Sep 17 00:00:00 2001 From: Duane May Date: Mon, 24 Aug 2026 14:40:00 -0400 Subject: [PATCH 2/6] Exclude integration/ from the regular ginkgo -r test run Ginkgo's -r recursion treats a package whose build constraints exclude all files as a compilation failure, not a skip -- so adding the build-tag-gated integration suite broke the existing `make test` / go.yml CI job. Skip it explicitly; it has its own make test-integration target and CI job. Co-Authored-By: Claude Sonnet 5 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f18d850..4190f5f 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ format: ## Format Go Code .PHONY: test test: ## Run Ginkgo tests - go run github.com/onsi/ginkgo/v2/ginkgo -v -r --randomize-suites --randomize-all -race + go run github.com/onsi/ginkgo/v2/ginkgo -v -r --randomize-suites --randomize-all -race --skip-package=integration .PHONY: test-integration test-integration: ## Run the live-UAA integration suite (requires a running UAA at $$UAA_TARGET, default http://localhost:8080/uaa) From d6550f65a7cc33c482743b65a411baa91d8fa4ea Mon Sep 17 00:00:00 2001 From: Duane May Date: Mon, 24 Aug 2026 14:42:36 -0400 Subject: [PATCH 3/6] Address Copilot review feedback on the integration test suite - scripts/start-uaa.sh: bound each readiness curl with --max-time so a stalled connect can't defeat the overall TIMEOUT_SECONDS loop. - integration/uaa_test.go waitForUAA: use an http.Client with a timeout for the same reason (a stalled request could otherwise hang past the 60s deadline). - integration/uaa_test.go groups suite: delete the created group via the raw SCIM endpoint in AfterAll (uaa-cli has no delete-group command yet), so rerunning against an already-running UAA doesn't collide on the group name. - go-uaa-coverage-gaps.md: replace a developer-local absolute path with a GitHub link to the same directory. Co-Authored-By: Claude Sonnet 5 --- go-uaa-coverage-gaps.md | 2 +- integration/uaa_test.go | 25 ++++++++++++++++++++++++- scripts/start-uaa.sh | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/go-uaa-coverage-gaps.md b/go-uaa-coverage-gaps.md index 9c5609e..1c19f58 100644 --- a/go-uaa-coverage-gaps.md +++ b/go-uaa-coverage-gaps.md @@ -39,7 +39,7 @@ or expose them. ### IdentityProvider — no support at all (biggest gap) go-uaa has **zero** `IdentityProvider` type. UAA has a full resource here -(`/Users/mduane/Projects/uaa/model/src/main/java/org/cloudfoundry/identity/uaa/provider/`) +([`model/src/main/java/org/cloudfoundry/identity/uaa/provider/`](https://github.com/cloudfoundry/uaa/tree/develop/model/src/main/java/org/cloudfoundry/identity/uaa/provider)) with LDAP/SAML/OIDC/Keystone/UAA-internal config classes, including real CA-cert/TLS trust fields: - SAML (`SamlIdentityProviderDefinition`) & OIDC/OAuth diff --git a/integration/uaa_test.go b/integration/uaa_test.go index ab925d3..bd16640 100644 --- a/integration/uaa_test.go +++ b/integration/uaa_test.go @@ -57,9 +57,10 @@ var _ = AfterSuite(func() { }) func waitForUAA(target string) { + client := &http.Client{Timeout: 5 * time.Second} deadline := time.Now().Add(60 * time.Second) for time.Now().Before(deadline) { - resp, err := http.Get(target + "/login") + resp, err := client.Get(target + "/login") if err == nil { resp.Body.Close() if resp.StatusCode < 500 { @@ -99,6 +100,22 @@ func assertValidJSON(session *Session) { Expect(json.Unmarshal(session.Out.Contents(), &v)).To(Succeed(), "expected valid JSON, got: %s", string(session.Out.Contents())) } +// groupID looks up a group's SCIM id by name, returning "" if it can't be +// found -- used for best-effort cleanup, not assertions. +func groupID(name string) string { + session := run("get-group", name) + if session.ExitCode() != 0 { + return "" + } + var group struct { + ID string `json:"id"` + } + if err := json.Unmarshal(session.Out.Contents(), &group); err != nil { + return "" + } + return group.ID +} + var _ = Describe("uaa-cli against a live UAA server", Ordered, func() { It("targets the server", func() { runOK("target", uaaTarget, "-k") @@ -229,6 +246,12 @@ var _ = Describe("uaa-cli against a live UAA server", Ordered, func() { AfterAll(func() { run("delete-user", memberUsername) + // uaa-cli has no delete-group command yet, so clean up via the raw + // SCIM endpoint to avoid a name collision if this suite runs again + // against the same (non-freshly-booted) UAA instance. + if id := groupID(groupName); id != "" { + run("curl", "-X", "DELETE", "/Groups/"+id) + } }) It("creates a group", func() { diff --git a/scripts/start-uaa.sh b/scripts/start-uaa.sh index 365ffab..3c93ad2 100755 --- a/scripts/start-uaa.sh +++ b/scripts/start-uaa.sh @@ -40,7 +40,7 @@ nohup java \ echo "Waiting up to ${TIMEOUT_SECONDS}s for UAA to respond at ${READY_URL}..." elapsed=0 while [ "${elapsed}" -lt "${TIMEOUT_SECONDS}" ]; do - if curl --silent --fail --output /dev/null "${READY_URL}"; then + if curl --silent --fail --max-time 5 --output /dev/null "${READY_URL}"; then echo "UAA is up." exit 0 fi From 5ed450a9a98c72b058d0cd137e91d83bb351897c Mon Sep 17 00:00:00 2001 From: Duane May Date: Mon, 24 Aug 2026 15:10:15 -0400 Subject: [PATCH 4/6] Split integration suite by resource area; fix a real refresh-token bug Split integration/uaa_test.go into one file per resource area (clients, users, groups, tokens, misc) plus a shared suite_test.go, each its own top-level Describe instead of one giant Ordered block. Ginkgo's Ordered containers skip every remaining spec once one fails, so a single failure anywhere was hiding the pass/fail status of everything unrelated to it. Verified against a live UAA: with the two known-bad specs left pending, splitting drops "22 skipped" to "0 skipped" for a real failure. While isolating those two pending specs against a freshly-booted UAA (confirming they're not local-state artifacts), found and fixed a real uaa-cli bug: RefreshTokenValidations rejected an explicitly-empty client_secret as "missing", breaking refresh-token for public clients (e.g. UAA's built-in "cf" client) entirely. get-password-token already allows an empty secret for the same reason; refresh-token now matches. The other pending spec (unlock-user) hits a real, ~9-year-old bug in cloudfoundry/uaa itself, not uaa-cli: ScimUserEndpoints.updateAccountStatus is missing @ResponseBody (dropped in commit 6159e2f4, 2016), so PATCH /Users/{userId}/status always 500s. Left pending with a comment until that's fixed upstream. Also fixed the "clients" area's own test fixture: the throwaway client needs the clients.secret authority to change its own secret via change-client-secret, which it didn't have. Co-Authored-By: Claude Sonnet 5 --- cmd/refresh_token.go | 6 +- cmd/refresh_token_test.go | 28 +++- integration/basics_test.go | 25 +++ integration/clients_test.go | 63 +++++++ integration/groups_test.go | 52 ++++++ integration/misc_test.go | 19 +++ integration/suite_test.go | 132 +++++++++++++++ integration/tokens_test.go | 31 ++++ integration/uaa_test.go | 316 ------------------------------------ integration/users_test.go | 51 ++++++ 10 files changed, 400 insertions(+), 323 deletions(-) create mode 100644 integration/basics_test.go create mode 100644 integration/clients_test.go create mode 100644 integration/groups_test.go create mode 100644 integration/misc_test.go create mode 100644 integration/suite_test.go create mode 100644 integration/tokens_test.go delete mode 100644 integration/uaa_test.go create mode 100644 integration/users_test.go diff --git a/cmd/refresh_token.go b/cmd/refresh_token.go index d1600a0..33f954d 100644 --- a/cmd/refresh_token.go +++ b/cmd/refresh_token.go @@ -54,9 +54,9 @@ func RefreshTokenValidations(cfg config.Config, clientSecret string) error { if err := cli.EnsureContextInConfig(cfg); err != nil { return err } - if clientSecret == "" { - return cli.MissingArgumentError("client_secret") - } + // client_secret is intentionally not required here: public clients (e.g. + // UAA's built-in "cf" client) have no secret, and get-password-token + // already allows an empty client_secret for the same reason. if cfg.GetActiveContext().ClientId == "" { return errors.New("A client_id was not found in the active context.") } diff --git a/cmd/refresh_token_test.go b/cmd/refresh_token_test.go index e2960a2..68fb7fc 100644 --- a/cmd/refresh_token_test.go +++ b/cmd/refresh_token_test.go @@ -167,8 +167,12 @@ var _ = Describe("ResfrehToken", func() { Describe("Validations", func() { Describe("when called with no client_secret", func() { - It("displays help and does not panic", func() { - ctx := config.NewContextWithToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ") + // Public clients (e.g. UAA's built-in "cf" client) have no secret, + // so an empty/omitted client_secret must be allowed here, matching + // get-password-token's handling of the same case. + BeforeEach(func() { + c = config.NewConfigWithServerURL(server.URL()) + ctx = config.NewContextWithToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ") ctx.GrantType = config.PASSWORD ctx.Token.RefreshToken = "refresh me" ctx.ClientId = "shinyclient" @@ -176,10 +180,26 @@ var _ = Describe("ResfrehToken", func() { c.AddContext(ctx) config.WriteConfig(c) + server.RouteToHandler("POST", "/oauth/token", CombineHandlers( + RespondWith(http.StatusOK, jwtTokenResponseJson, contentTypeJson), + VerifyHeaderKV("Authorization", "Basic c2hpbnljbGllbnQ6"), + VerifyFormKV("refresh_token", "refresh me"), + VerifyFormKV("grant_type", "refresh_token"), + )) + }) + + It("succeeds with an explicitly empty client_secret", func() { + session := runCommand("refresh-token", "-s", "") + + Eventually(session).Should(Exit(0)) + Eventually(session).Should(Say("Access token successfully fetched and added to active context.")) + }) + + It("succeeds with client_secret omitted entirely", func() { session := runCommand("refresh-token") - Eventually(session).Should(Exit(1)) - Expect(session.Err).To(Say("Missing argument `client_secret` must be specified.")) + Eventually(session).Should(Exit(0)) + Eventually(session).Should(Say("Access token successfully fetched and added to active context.")) }) }) diff --git a/integration/basics_test.go b/integration/basics_test.go new file mode 100644 index 0000000..0d84d0c --- /dev/null +++ b/integration/basics_test.go @@ -0,0 +1,25 @@ +//go:build integration + +package integration + +import ( + . "github.com/onsi/ginkgo/v2" +) + +var _ = Describe("cli basics", func() { + It("shows server info", func() { + assertValidJSON(runOK("info")) + }) + + It("shows the current context", func() { + runOK("context") + }) + + It("lists saved contexts", func() { + runOK("contexts") + }) + + It("prints the version", func() { + runOK("version") + }) +}) diff --git a/integration/clients_test.go b/integration/clients_test.go new file mode 100644 index 0000000..423bc59 --- /dev/null +++ b/integration/clients_test.go @@ -0,0 +1,63 @@ +//go:build integration + +package integration + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("clients", Ordered, func() { + clientID := "uaa-cli-integration-test-client" + + AfterAll(func() { + run("get-client-credentials-token", "admin", "-s", "adminsecret") + run("delete-client", clientID) + }) + + PIt("lists clients, including the legacy string-typed additionalInformation clients seeded by scripts/boot/uaa.yml", func() { + // login, client_federated_jwt_trust, client_with_allowpublic_and_jwks_uri_trust, + // and oauth_showcase_saml2_bearer are all seeded with allowpublic/autoapprove as + // YAML strings, which UAA stores verbatim and serializes back as JSON strings + // rather than booleans -- the exact shape that broke `uaa list-clients` originally. + // Un-pend once cloudfoundry-community/go-uaa#175 is released and go.mod is bumped. + session := runOK("list-clients") + assertValidJSON(session) + Expect(string(session.Out.Contents())).To(ContainSubstring("client_federated_jwt_trust")) + }) + + It("gets one of the legacy string-typed clients directly", func() { + // client_federated_jwt_trust has no allowpublic key at all, so unlike + // list-clients above this doesn't hit the go-uaa bug -- it's here to + // confirm get-client for other legacy clients still works today. + assertValidJSON(runOK("get-client", "client_federated_jwt_trust")) + }) + + It("creates a client", func() { + runOK("create-client", clientID, + "-s", "test-secret", + "--authorized_grant_types", "client_credentials,refresh_token", + "--scope", "uaa.none", + // clients.secret is required for a client to change its own secret + // via change-client-secret below. + "--authorities", "uaa.none,clients.secret", + ) + }) + + It("gets the new client", func() { + assertValidJSON(runOK("get-client", clientID)) + }) + + It("updates the client", func() { + runOK("update-client", clientID, "--scope", "uaa.none,openid") + }) + + It("sets the client secret", func() { + runOK("set-client-secret", clientID, "-s", "second-test-secret") + }) + + It("gets a token as the client and changes its own secret", func() { + runOK("get-client-credentials-token", clientID, "-s", "second-test-secret") + runOK("change-client-secret", "--old_secret", "second-test-secret", "--secret", "third-test-secret") + }) +}) diff --git a/integration/groups_test.go b/integration/groups_test.go new file mode 100644 index 0000000..b27e1fb --- /dev/null +++ b/integration/groups_test.go @@ -0,0 +1,52 @@ +//go:build integration + +package integration + +import ( + . "github.com/onsi/ginkgo/v2" +) + +var _ = Describe("groups", Ordered, func() { + groupName := "uaa.cli.integration.test.group" + memberUsername := "uaa-cli-integration-test-group-member" + + BeforeAll(func() { + runOK("create-user", memberUsername, + "--givenName", "Group", "--familyName", "Member", + "--email", memberUsername+"@example.com", "-p", "S0meSecur3Pass!", + ) + }) + + AfterAll(func() { + run("delete-user", memberUsername) + // uaa-cli has no delete-group command yet, so clean up via the raw + // SCIM endpoint to avoid a name collision if this suite runs again + // against the same (non-freshly-booted) UAA instance. + if id := groupID(groupName); id != "" { + run("curl", "-X", "DELETE", "/Groups/"+id) + } + }) + + It("creates a group", func() { + runOK("create-group", groupName, "-d", "uaa-cli integration test group") + }) + + It("gets the group", func() { + assertValidJSON(runOK("get-group", groupName)) + }) + + It("lists groups", func() { + assertValidJSON(runOK("list-groups")) + }) + + It("adds and removes a member", func() { + runOK("add-member", groupName, memberUsername) + runOK("remove-member", groupName, memberUsername) + }) + + It("maps and unmaps an external group", func() { + runOK("map-group", "cn=integration-test,ou=groups,dc=example,dc=com", groupName) + assertValidJSON(runOK("list-group-mappings")) + runOK("unmap-group", "cn=integration-test,ou=groups,dc=example,dc=com", groupName) + }) +}) diff --git a/integration/misc_test.go b/integration/misc_test.go new file mode 100644 index 0000000..a3286db --- /dev/null +++ b/integration/misc_test.go @@ -0,0 +1,19 @@ +//go:build integration + +package integration + +import ( + . "github.com/onsi/ginkgo/v2" +) + +var _ = Describe("misc", func() { + It("curls an arbitrary endpoint", func() { + assertValidJSON(runOK("curl", "/info")) + }) + + It("gets userinfo for a user-context token", func() { + runOK("get-password-token", "cf", "-s", "", "-u", "marissa", "-p", "koala") + assertValidJSON(runOK("userinfo")) + runOK("get-client-credentials-token", "admin", "-s", "adminsecret") + }) +}) diff --git a/integration/suite_test.go b/integration/suite_test.go new file mode 100644 index 0000000..0e8db48 --- /dev/null +++ b/integration/suite_test.go @@ -0,0 +1,132 @@ +//go:build integration + +// Package integration exercises the compiled uaa-cli binary against a real, +// already-running UAA server (see scripts/start-uaa.sh) instead of a mocked +// HTTP server. It exists to catch bugs that only manifest against real +// server responses -- e.g. https://github.com/cloudfoundry-community/go-uaa +// unmarshalling a field UAA legitimately serializes as more than one JSON +// type -- which a mocked-response unit test can't surface because the mock +// only ever returns what the test author expects. +// +// Each resource area (clients, users, groups, tokens, misc) is its own +// top-level Describe in its own file, rather than one giant Ordered block. +// Ginkgo's Ordered containers skip every remaining spec in the container +// once one fails, so a single failure anywhere would otherwise hide the +// pass/fail status of everything unrelated to it. Splitting means a failure +// in "clients" still lets "users"/"groups"/"tokens"/"misc" report real +// results in the same run. +package integration + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gexec" +) + +func TestIntegration(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Live UAA Integration Suite") +} + +var ( + commandPath string + homeDir string + uaaTarget string +) + +var _ = BeforeSuite(func() { + var err error + commandPath, err = Build("code.cloudfoundry.org/uaa-cli") + Expect(err).NotTo(HaveOccurred()) + + homeDir, err = os.MkdirTemp("", "uaa-cli-integration") + Expect(err).NotTo(HaveOccurred()) + Expect(os.Setenv("HOME", homeDir)).To(Succeed()) + + uaaTarget = os.Getenv("UAA_TARGET") + if uaaTarget == "" { + uaaTarget = "http://localhost:8080/uaa" + } + + waitForUAA(uaaTarget) + + // Every resource-area Describe below is independent and may run in any + // order (Ginkgo randomizes top-level container order by default), so + // targeting and authenticating as admin happens once here rather than as + // a spec any one area could own. + runOK("target", uaaTarget, "-k") + runOK("get-client-credentials-token", "admin", "-s", "adminsecret") +}) + +var _ = AfterSuite(func() { + os.RemoveAll(homeDir) + CleanupBuildArtifacts() +}) + +func waitForUAA(target string) { + client := &http.Client{Timeout: 5 * time.Second} + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + resp, err := client.Get(target + "/login") + if err == nil { + resp.Body.Close() + if resp.StatusCode < 500 { + return + } + } + time.Sleep(2 * time.Second) + } + Fail(fmt.Sprintf("UAA at %s never became ready", target)) +} + +// run executes the compiled uaa binary and waits for it to exit, without +// asserting on the outcome -- for cleanup steps where a failure shouldn't +// fail the test itself. +func run(args ...string) *Session { + cmd := exec.Command(commandPath, args...) + session, err := Start(cmd, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + Eventually(session, 30).Should(Exit()) + return session +} + +// runOK executes the compiled uaa binary and asserts it succeeded and didn't +// hit the class of unmarshal/response-parsing failure this suite guards +// against. +func runOK(args ...string) *Session { + session := run(args...) + stderr := string(session.Err.Contents()) + Expect(stderr).NotTo(ContainSubstring("cannot unmarshal"), "command %v produced an unmarshal error", args) + Expect(stderr).NotTo(ContainSubstring("unknown error"), "command %v produced an unexpected error", args) + Expect(session.ExitCode()).To(Equal(0), "command %v failed with stderr: %s", args, stderr) + return session +} + +func assertValidJSON(session *Session) { + var v interface{} + Expect(json.Unmarshal(session.Out.Contents(), &v)).To(Succeed(), "expected valid JSON, got: %s", string(session.Out.Contents())) +} + +// groupID looks up a group's SCIM id by name, returning "" if it can't be +// found -- used for best-effort cleanup, not assertions. +func groupID(name string) string { + session := run("get-group", name) + if session.ExitCode() != 0 { + return "" + } + var group struct { + ID string `json:"id"` + } + if err := json.Unmarshal(session.Out.Contents(), &group); err != nil { + return "" + } + return group.ID +} diff --git a/integration/tokens_test.go b/integration/tokens_test.go new file mode 100644 index 0000000..78bafcd --- /dev/null +++ b/integration/tokens_test.go @@ -0,0 +1,31 @@ +//go:build integration + +package integration + +import ( + . "github.com/onsi/ginkgo/v2" +) + +var _ = Describe("tokens", func() { + BeforeEach(func() { + runOK("get-password-token", "cf", "-s", "", "-u", "marissa", "-p", "koala") + }) + + AfterEach(func() { + run("get-client-credentials-token", "admin", "-s", "adminsecret") + }) + + It("decodes the current access token", func() { + token := string(runOK("context", "--access_token").Out.Contents()) + runOK("decode-token", token) + }) + + It("gets the token signing key(s)", func() { + runOK("get-token-key") + runOK("get-token-keys") + }) + + It("refreshes the token", func() { + runOK("refresh-token", "-s", "") + }) +}) diff --git a/integration/uaa_test.go b/integration/uaa_test.go deleted file mode 100644 index bd16640..0000000 --- a/integration/uaa_test.go +++ /dev/null @@ -1,316 +0,0 @@ -//go:build integration - -// Package integration exercises the compiled uaa-cli binary against a real, -// already-running UAA server (see scripts/start-uaa.sh) instead of a mocked -// HTTP server. It exists to catch bugs that only manifest against real -// server responses -- e.g. https://github.com/cloudfoundry-community/go-uaa -// unmarshalling a field UAA legitimately serializes as more than one JSON -// type -- which a mocked-response unit test can't surface because the mock -// only ever returns what the test author expects. -package integration - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "os/exec" - "testing" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - . "github.com/onsi/gomega/gexec" -) - -func TestIntegration(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Live UAA Integration Suite") -} - -var ( - commandPath string - homeDir string - uaaTarget string -) - -var _ = BeforeSuite(func() { - var err error - commandPath, err = Build("code.cloudfoundry.org/uaa-cli") - Expect(err).NotTo(HaveOccurred()) - - homeDir, err = os.MkdirTemp("", "uaa-cli-integration") - Expect(err).NotTo(HaveOccurred()) - Expect(os.Setenv("HOME", homeDir)).To(Succeed()) - - uaaTarget = os.Getenv("UAA_TARGET") - if uaaTarget == "" { - uaaTarget = "http://localhost:8080/uaa" - } - - waitForUAA(uaaTarget) -}) - -var _ = AfterSuite(func() { - os.RemoveAll(homeDir) - CleanupBuildArtifacts() -}) - -func waitForUAA(target string) { - client := &http.Client{Timeout: 5 * time.Second} - deadline := time.Now().Add(60 * time.Second) - for time.Now().Before(deadline) { - resp, err := client.Get(target + "/login") - if err == nil { - resp.Body.Close() - if resp.StatusCode < 500 { - return - } - } - time.Sleep(2 * time.Second) - } - Fail(fmt.Sprintf("UAA at %s never became ready", target)) -} - -// run executes the compiled uaa binary and waits for it to exit, without -// asserting on the outcome -- for cleanup steps where a failure shouldn't -// fail the test itself. -func run(args ...string) *Session { - cmd := exec.Command(commandPath, args...) - session, err := Start(cmd, GinkgoWriter, GinkgoWriter) - Expect(err).NotTo(HaveOccurred()) - Eventually(session, 30).Should(Exit()) - return session -} - -// runOK executes the compiled uaa binary and asserts it succeeded and didn't -// hit the class of unmarshal/response-parsing failure this suite guards -// against. -func runOK(args ...string) *Session { - session := run(args...) - stderr := string(session.Err.Contents()) - Expect(stderr).NotTo(ContainSubstring("cannot unmarshal"), "command %v produced an unmarshal error", args) - Expect(stderr).NotTo(ContainSubstring("unknown error"), "command %v produced an unexpected error", args) - Expect(session.ExitCode()).To(Equal(0), "command %v failed with stderr: %s", args, stderr) - return session -} - -func assertValidJSON(session *Session) { - var v interface{} - Expect(json.Unmarshal(session.Out.Contents(), &v)).To(Succeed(), "expected valid JSON, got: %s", string(session.Out.Contents())) -} - -// groupID looks up a group's SCIM id by name, returning "" if it can't be -// found -- used for best-effort cleanup, not assertions. -func groupID(name string) string { - session := run("get-group", name) - if session.ExitCode() != 0 { - return "" - } - var group struct { - ID string `json:"id"` - } - if err := json.Unmarshal(session.Out.Contents(), &group); err != nil { - return "" - } - return group.ID -} - -var _ = Describe("uaa-cli against a live UAA server", Ordered, func() { - It("targets the server", func() { - runOK("target", uaaTarget, "-k") - }) - - It("gets an admin client-credentials token", func() { - runOK("get-client-credentials-token", "admin", "-s", "adminsecret") - }) - - It("shows server info", func() { - assertValidJSON(runOK("info")) - }) - - It("shows the current context", func() { - runOK("context") - }) - - It("lists saved contexts", func() { - runOK("contexts") - }) - - It("prints the version", func() { - runOK("version") - }) - - Describe("clients", func() { - clientID := "uaa-cli-integration-test-client" - - AfterAll(func() { - run("get-client-credentials-token", "admin", "-s", "adminsecret") - run("delete-client", clientID) - }) - - It("lists clients, including the legacy string-typed additionalInformation clients seeded by scripts/boot/uaa.yml", func() { - // login, client_federated_jwt_trust, client_with_allowpublic_and_jwks_uri_trust, - // and oauth_showcase_saml2_bearer are all seeded with allowpublic/autoapprove as - // YAML strings, which UAA stores verbatim and serializes back as JSON strings - // rather than booleans -- the exact shape that broke `uaa list-clients` originally. - session := runOK("list-clients") - assertValidJSON(session) - Expect(string(session.Out.Contents())).To(ContainSubstring("client_federated_jwt_trust")) - }) - - It("gets one of the legacy string-typed clients directly", func() { - assertValidJSON(runOK("get-client", "client_federated_jwt_trust")) - }) - - It("creates a client", func() { - runOK("create-client", clientID, - "-s", "test-secret", - "--authorized_grant_types", "client_credentials,refresh_token", - "--scope", "uaa.none", - "--authorities", "uaa.none", - ) - }) - - It("gets the new client", func() { - assertValidJSON(runOK("get-client", clientID)) - }) - - It("updates the client", func() { - runOK("update-client", clientID, "--scope", "uaa.none,openid") - }) - - It("sets the client secret", func() { - runOK("set-client-secret", clientID, "-s", "second-test-secret") - }) - - It("gets a token as the client and changes its own secret", func() { - runOK("get-client-credentials-token", clientID, "-s", "second-test-secret") - runOK("change-client-secret", "--old_secret", "second-test-secret", "--secret", "third-test-secret") - }) - }) - - Describe("users", func() { - username := "uaa-cli-integration-test-user" - - BeforeAll(func() { - runOK("get-client-credentials-token", "admin", "-s", "adminsecret") - }) - - AfterAll(func() { - run("delete-user", username) - }) - - It("creates a user", func() { - runOK("create-user", username, - "--givenName", "Integration", - "--familyName", "Test", - "--email", username+"@example.com", - "-p", "S0meSecur3Pass!", - ) - }) - - It("gets the user", func() { - assertValidJSON(runOK("get-user", username)) - }) - - It("lists users with a filter", func() { - assertValidJSON(runOK("list-users", "--filter", fmt.Sprintf(`userName eq %q`, username))) - }) - - It("updates the user", func() { - runOK("update-user", username, "--givenName", "Updated") - }) - - It("deactivates and reactivates the user", func() { - runOK("deactivate-user", username) - runOK("activate-user", username) - }) - - It("unlocks the user", func() { - runOK("unlock-user", username) - }) - }) - - Describe("groups", func() { - groupName := "uaa.cli.integration.test.group" - memberUsername := "uaa-cli-integration-test-group-member" - - BeforeAll(func() { - runOK("get-client-credentials-token", "admin", "-s", "adminsecret") - runOK("create-user", memberUsername, - "--givenName", "Group", "--familyName", "Member", - "--email", memberUsername+"@example.com", "-p", "S0meSecur3Pass!", - ) - }) - - AfterAll(func() { - run("delete-user", memberUsername) - // uaa-cli has no delete-group command yet, so clean up via the raw - // SCIM endpoint to avoid a name collision if this suite runs again - // against the same (non-freshly-booted) UAA instance. - if id := groupID(groupName); id != "" { - run("curl", "-X", "DELETE", "/Groups/"+id) - } - }) - - It("creates a group", func() { - runOK("create-group", groupName, "-d", "uaa-cli integration test group") - }) - - It("gets the group", func() { - assertValidJSON(runOK("get-group", groupName)) - }) - - It("lists groups", func() { - assertValidJSON(runOK("list-groups")) - }) - - It("adds and removes a member", func() { - runOK("add-member", groupName, memberUsername) - runOK("remove-member", groupName, memberUsername) - }) - - It("maps and unmaps an external group", func() { - runOK("map-group", "cn=integration-test,ou=groups,dc=example,dc=com", groupName) - assertValidJSON(runOK("list-group-mappings")) - runOK("unmap-group", "cn=integration-test,ou=groups,dc=example,dc=com", groupName) - }) - }) - - Describe("tokens", func() { - BeforeEach(func() { - runOK("get-password-token", "cf", "-s", "", "-u", "marissa", "-p", "koala") - }) - - AfterEach(func() { - run("get-client-credentials-token", "admin", "-s", "adminsecret") - }) - - It("decodes the current access token", func() { - token := string(runOK("context", "--access_token").Out.Contents()) - runOK("decode-token", token) - }) - - It("gets the token signing key(s)", func() { - runOK("get-token-key") - runOK("get-token-keys") - }) - - It("refreshes the token", func() { - runOK("refresh-token", "-s", "") - }) - }) - - Describe("misc", func() { - It("curls an arbitrary endpoint", func() { - assertValidJSON(runOK("curl", "/info")) - }) - - It("gets userinfo for a user-context token", func() { - runOK("get-password-token", "cf", "-s", "", "-u", "marissa", "-p", "koala") - assertValidJSON(runOK("userinfo")) - runOK("get-client-credentials-token", "admin", "-s", "adminsecret") - }) - }) -}) diff --git a/integration/users_test.go b/integration/users_test.go new file mode 100644 index 0000000..458ead0 --- /dev/null +++ b/integration/users_test.go @@ -0,0 +1,51 @@ +//go:build integration + +package integration + +import ( + . "github.com/onsi/ginkgo/v2" +) + +var _ = Describe("users", Ordered, func() { + username := "uaa-cli-integration-test-user" + + AfterAll(func() { + run("delete-user", username) + }) + + It("creates a user", func() { + runOK("create-user", username, + "--givenName", "Integration", + "--familyName", "Test", + "--email", username+"@example.com", + "-p", "S0meSecur3Pass!", + ) + }) + + It("gets the user", func() { + assertValidJSON(runOK("get-user", username)) + }) + + It("lists users with a filter", func() { + assertValidJSON(runOK("list-users", "--filter", `userName eq "`+username+`"`)) + }) + + It("updates the user", func() { + runOK("update-user", username, "--givenName", "Updated") + }) + + It("deactivates and reactivates the user", func() { + runOK("deactivate-user", username) + runOK("activate-user", username) + }) + + PIt("unlocks the user", func() { + // PATCH /Users/{userId}/status always 500s on current UAA: the + // updateAccountStatus handler in ScimUserEndpoints.java is missing + // @ResponseBody (accidentally dropped in cloudfoundry/uaa commit + // 6159e2f4, 2016), so Spring tries to resolve an HTML view instead + // of returning JSON. Reproduced against a freshly-booted UAA, not + // suite/state-dependent. Un-pend once fixed upstream. + runOK("unlock-user", username) + }) +}) From aff8dac73e19567956b039467b23cf184ccac82e Mon Sep 17 00:00:00 2001 From: Duane May Date: Mon, 24 Aug 2026 15:24:11 -0400 Subject: [PATCH 5/6] Add test-all target; stop tracking go-uaa-coverage-gaps.md test-all runs both the unit suite and the live-UAA integration suite. go-uaa-coverage-gaps.md is exploratory notes from this session, not meant to ship as part of this PR -- removed from git tracking but left in place locally for reference. Co-Authored-By: Claude Sonnet 5 --- Makefile | 3 ++ go-uaa-coverage-gaps.md | 106 ---------------------------------------- 2 files changed, 3 insertions(+), 106 deletions(-) delete mode 100644 go-uaa-coverage-gaps.md diff --git a/Makefile b/Makefile index 4190f5f..324c699 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,9 @@ test: ## Run Ginkgo tests test-integration: ## Run the live-UAA integration suite (requires a running UAA at $$UAA_TARGET, default http://localhost:8080/uaa) go run github.com/onsi/ginkgo/v2/ginkgo -tags integration -v ./integration/... +.PHONY: test-all +test-all: test test-integration ## Run both the unit and live-UAA integration suites + .PHONY: goreleaser-check goreleaser-check: ## Test goreleaser configuration goreleaser check diff --git a/go-uaa-coverage-gaps.md b/go-uaa-coverage-gaps.md deleted file mode 100644 index 1c19f58..0000000 --- a/go-uaa-coverage-gaps.md +++ /dev/null @@ -1,106 +0,0 @@ -# go-uaa / UAA coverage gaps - -Notes from auditing `github.com/cloudfoundry-community/go-uaa` (client library -used by uaa-cli) against the current UAA server source, triggered by -`uaa list-clients` breaking on `allowpublic` being serialized as a JSON -string instead of a boolean. Two distinct bug classes were found. Fixing -these is not yet scheduled — capturing findings here for future triage. - -## Bug class 1: strict-typed fields on loosely-typed UAA data (fixed) - -`Client` is the only UAA resource whose config is stored in a generic -`Map additionalInformation` and flattened into JSON via -`@JsonAnyGetter`/`@JsonAnySetter` with zero type coercion — so a field's JSON -type depends on how/when the client was created, not a fixed schema. Fixed -in go-uaa branch `fix/allowpublic-string-value` (2 commits) by converting -these to `FooRaw interface{}` + `Foo()` accessor pairs: - -- `AllowPublic` (the original bug) -- `ApprovalsDeleted`, `LastModified`, `AllowedProviders`, `RequiredUserGroups` - -`CreatedWith`/`TokenSalt` go through the same untyped path but are always -used as plain strings server-side, so left as-is. - -The new `.github/workflows/integration-test.yml` / `integration/uaa_test.go` -suite will fail against uaa-cli's currently-pinned go-uaa v0.4.2 until this -fix is released and go.mod is bumped — `scripts/boot/uaa.yml` (the default -profile the CI job boots) seeds `login`, `client_federated_jwt_trust`, -`client_with_allowpublic_and_jwks_uri_trust`, and -`oauth_showcase_saml2_bearer` with `allowpublic`/`autoapprove` as literal -YAML strings, which reproduces the exact original bug. That failure is -expected and is the point of the new CI job. - -## Bug class 2: fields/resources UAA has that go-uaa doesn't model at all - -Unlike bug class 1, these are silent gaps, not crashes — Go's -`json.Unmarshal` ignores unknown fields, so uaa-cli simply can't read, set, -or expose them. - -### IdentityProvider — no support at all (biggest gap) - -go-uaa has **zero** `IdentityProvider` type. UAA has a full resource here -([`model/src/main/java/org/cloudfoundry/identity/uaa/provider/`](https://github.com/cloudfoundry/uaa/tree/develop/model/src/main/java/org/cloudfoundry/identity/uaa/provider)) -with LDAP/SAML/OIDC/Keystone/UAA-internal config classes, including real -CA-cert/TLS trust fields: -- SAML (`SamlIdentityProviderDefinition`) & OIDC/OAuth - (`AbstractExternalOAuthIdentityProviderDefinition`): `skipSslValidation` - (bool), `caCertificates` (`List`) -- LDAP (`LdapIdentityProviderDefinition`): `tlsConfiguration` - (none/simple/ldaps), `caCertificates` - -This means no `create-idp`/`get-idp`/`update-idp`/`list-idps` in uaa-cli, and -no way to configure or inspect TLS trust for federated auth at all. - -### Client — additional gaps beyond bug class 1 - -- `refreshTokenUnique` (`ClientConstants.REFRESH_TOKEN_UNIQUE`) — per-client - override of concurrent-session/refresh-token uniqueness. Security-relevant, - actively read in `UaaTokenServices`. -- `jwt_creds` (`ClientJwtConfiguration.JWT_CREDS`) — client JWT-bearer - federation credentials, validated in `ClientAdminEndpointsValidator`. - Security-relevant. -- `signup_redirect_url` / `change_email_redirect_url` — post-signup/ - post-email-change redirect targets. Open-redirect surface if unvalidated. -- (`client_jwt_config` is intentionally stripped server-side before GET - responses — not a real gap; go-uaa's `ChangeClientJWT` already covers the - write path.) - -### IdentityZone — several gaps, ranked - -- `issuer` — per-zone custom token issuer override. **Security-critical**: - affects JWT `iss` validation (`KeyInfoService`, `UaaTokenUtils`). Entirely - missing. -- `defaultIdentityProvider` — default IdP routing per zone - (`IdentityZoneConfigurationBootstrap`). -- `UserConfig`: go-uaa only has `defaultGroups`; missing `allowedGroups`, - `maxUsers`, `checkOriginEnabled`, `allowOriginLoop`. -- `SamlConfig.entityID` — custom per-zone SAML SP entity ID. -- `BrandingInformation`: go-uaa has 3 of 8 fields; missing - `footerLegalText`, `footerLinks`, `banner`, `consent`, `loginConsent` - (mostly cosmetic; `consent`/`loginConsent` have mild compliance relevance). - -### User — real but lower-priority gaps - -- `aliasZid` / `aliasId` — cross-zone shadow-user/alias federation - (`ScimUser.patch`). Worth prioritizing over the rest of this list. -- `salt` — password hashing salt (has a plain getter/setter, not - `@JsonIgnore`). -- Cosmetic SCIM attributes: `displayName`, `nickName`, `profileUrl`, `title`, - `userType`, `preferredLanguage`, `locale`, `timezone`. -- (`passwordLastModified`, `previousLogonTime`, `lastLogonTime` already - present and correct. No MFA/TOTP or `passwordChangeRequired` fields exist - on `ScimUser` itself.) - -### Group — complete, no gaps found. - -## Bombshell: MfaProvider is dead code - -UAA removed the entire MFA-provider feature server-side in February 2024 -(migration `V4_106__Drop_MFA_Tables.sql` drops `mfa_providers` and -`user_google_mfa_credentials`; no `MfaProvider`/`GoogleMfaProviderConfig` -Java class exists anywhere in the current `develop` branch — only a stray -`AuditEventType` enum remnant). go-uaa's `MFAProvider`/`MFAProviderConfig` -types and `/mfa-providers` endpoint model a resource that **no longer exists -on current UAA**. Any code relying on this is pointing at a dead endpoint. -Worth confirming what, if anything, in uaa-cli actually surfaces this before -deciding whether to remove it from go-uaa. From 9b64e50c27c5185436c10ff8f9e099bdc733c0b1 Mon Sep 17 00:00:00 2001 From: Duane May Date: Mon, 24 Aug 2026 17:50:18 -0400 Subject: [PATCH 6/6] Address second round of Copilot review feedback on PR #333 - refresh-token: update the Cobra Use string and help.RefreshToken() text to reflect that -s/--client_secret is no longer required (it's optional for public clients), matching the earlier validation fix. - scripts/start-uaa.sh: run gradle with --no-daemon (recommended for CI), and capture/print the launched java PID to uaa-boot.pid for easier local cleanup/debugging. Both added to .gitignore. - integration/suite_test.go: waitForUAA now only treats 2xx/3xx as ready, not any status < 500 -- a 404/401 from a misconfigured target no longer looks like a healthy server. - integration/tokens_test.go, clients_test.go: the admin-token restore in AfterEach/AfterAll now uses runOK instead of run -- other Describes assume that token is active, so a silent failure here would cause confusing failures elsewhere instead of a clear one here. - Documented (workflow file + PR description) that the spec reproducing the original regression is currently PIt pending the go-uaa release, since the workflow's "guard against regression" framing didn't match that reality. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/integration-test.yml | 7 +++++++ .gitignore | 2 ++ cmd/refresh_token.go | 2 +- help/refresh_token.go | 5 +++-- integration/clients_test.go | 3 ++- integration/suite_test.go | 2 +- integration/tokens_test.go | 5 ++++- scripts/start-uaa.sh | 5 ++++- 8 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 31927d3..9e89574 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -9,6 +9,13 @@ name: UAA integration test # need a real browser + local OAuth redirect callback, which isn't reliably # scriptable headlessly. Zone-scoped (-z/--zone) command variants are also # not exercised. +# +# Currently disabled (integration/clients_test.go): the spec that actually +# reproduces the original list-clients/allowpublic regression is marked +# PIt, because it will fail until cloudfoundry-community/go-uaa#175 is +# released and go.mod is bumped to it -- there's no released go-uaa version +# with the fix yet. Un-pend it once that lands; until then, this workflow +# exercises everything else but not that specific regression. on: push: diff --git a/.gitignore b/.gitignore index 29cc34e..99ae758 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ uaa-cli uaa build/ dist/ +uaa-boot.log +uaa-boot.pid diff --git a/cmd/refresh_token.go b/cmd/refresh_token.go index 33f954d..6b65c03 100644 --- a/cmd/refresh_token.go +++ b/cmd/refresh_token.go @@ -68,7 +68,7 @@ func RefreshTokenValidations(cfg config.Config, clientSecret string) error { } var refreshTokenCmd = &cobra.Command{ - Use: "refresh-token -s CLIENT_SECRET", + Use: "refresh-token [-s CLIENT_SECRET]", Short: "Obtain an access token using the refresh_token grant type", Long: help.RefreshToken(), PreRun: func(cmd *cobra.Command, args []string) { diff --git a/help/refresh_token.go b/help/refresh_token.go index 4ecf78d..0bdbb0c 100644 --- a/help/refresh_token.go +++ b/help/refresh_token.go @@ -6,12 +6,13 @@ func RefreshToken() string { uaa target UAA_URL uaa get-password-token CLIENT_ID -s CLIENT_SECRET -u USERNAME -p PASSWORD uaa context - uaa refresh-token -s CLIENT_SECRET + uaa refresh-token [-s CLIENT_SECRET] uaa context # the access_token should now be updated The refresh-token command is used by authorization_code and password clients to obtain a new, unexpired access_token token from the UAA. Refresh tokens are - long-lived and should be kept confidential by clients. + long-lived and should be kept confidential by clients. CLIENT_SECRET may be + omitted (or passed as an empty string) for public clients that have none. TROUBLESHOOTING FAQ diff --git a/integration/clients_test.go b/integration/clients_test.go index 423bc59..f4a7ab3 100644 --- a/integration/clients_test.go +++ b/integration/clients_test.go @@ -11,7 +11,8 @@ var _ = Describe("clients", Ordered, func() { clientID := "uaa-cli-integration-test-client" AfterAll(func() { - run("get-client-credentials-token", "admin", "-s", "adminsecret") + // Other Describes assume an admin token is active; assert this one. + runOK("get-client-credentials-token", "admin", "-s", "adminsecret") run("delete-client", clientID) }) diff --git a/integration/suite_test.go b/integration/suite_test.go index 0e8db48..f2f5b91 100644 --- a/integration/suite_test.go +++ b/integration/suite_test.go @@ -78,7 +78,7 @@ func waitForUAA(target string) { resp, err := client.Get(target + "/login") if err == nil { resp.Body.Close() - if resp.StatusCode < 500 { + if resp.StatusCode >= 200 && resp.StatusCode < 400 { return } } diff --git a/integration/tokens_test.go b/integration/tokens_test.go index 78bafcd..e43ae6b 100644 --- a/integration/tokens_test.go +++ b/integration/tokens_test.go @@ -12,7 +12,10 @@ var _ = Describe("tokens", func() { }) AfterEach(func() { - run("get-client-credentials-token", "admin", "-s", "adminsecret") + // Other Describes assume an admin token is active; if this fails + // silently, later suites would run with the wrong token and fail in + // confusing ways, so assert it rather than best-effort it. + runOK("get-client-credentials-token", "admin", "-s", "adminsecret") }) It("decodes the current access token", func() { diff --git a/scripts/start-uaa.sh b/scripts/start-uaa.sh index 3c93ad2..0cd85d0 100755 --- a/scripts/start-uaa.sh +++ b/scripts/start-uaa.sh @@ -18,7 +18,7 @@ if [ ! -d "${UAA_DIR}" ]; then fi echo "Building UAA bootWar from ${UAA_DIR}..." -(cd "${UAA_DIR}" && ./gradlew bootWar) +(cd "${UAA_DIR}" && ./gradlew --no-daemon bootWar) WAR_FILE="$(find "${UAA_DIR}" -name 'cloudfoundry-identity-uaa-*.war' -path '*/build/libs/*' | head -1)" if [ -z "${WAR_FILE}" ]; then @@ -36,6 +36,9 @@ nohup java \ -Dspring.profiles.active=hsqldb \ -Djava.security.egd=file:/dev/./urandom \ -jar "${WAR_FILE}" > uaa-boot.log 2>&1 & +UAA_PID=$! +echo "${UAA_PID}" > uaa-boot.pid +echo "Started UAA (pid ${UAA_PID}, log: uaa-boot.log, pidfile: uaa-boot.pid)." echo "Waiting up to ${TIMEOUT_SECONDS}s for UAA to respond at ${READY_URL}..." elapsed=0