diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..9e89574 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,75 @@ +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. +# +# 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: + 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/.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/Makefile b/Makefile index 03e7a9a..324c699 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,14 @@ 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) + 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 diff --git a/cmd/refresh_token.go b/cmd/refresh_token.go index d1600a0..6b65c03 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.") } @@ -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/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/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/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..f4a7ab3 --- /dev/null +++ b/integration/clients_test.go @@ -0,0 +1,64 @@ +//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() { + // Other Describes assume an admin token is active; assert this one. + runOK("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..f2f5b91 --- /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 >= 200 && resp.StatusCode < 400 { + 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..e43ae6b --- /dev/null +++ b/integration/tokens_test.go @@ -0,0 +1,34 @@ +//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() { + // 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() { + 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/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) + }) +}) diff --git a/scripts/start-uaa.sh b/scripts/start-uaa.sh new file mode 100755 index 0000000..0cd85d0 --- /dev/null +++ b/scripts/start-uaa.sh @@ -0,0 +1,56 @@ +#!/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 --no-daemon 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 & +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 +while [ "${elapsed}" -lt "${TIMEOUT_SECONDS}" ]; do + if curl --silent --fail --max-time 5 --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