From 71c69fbb8a4b02909b4b01c2ce2f31f3feacc6e3 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 14:56:01 +0100 Subject: [PATCH 01/32] Allow test golden files and GitHub config in gitignore Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 1cf384b..5e01dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,10 @@ Temporary Items *.yaml *.yml +# Allow test golden files and GitHub config +!**/testdata/** +!.github/** + dist/ /imscli go.mod.local From 82ad9eef0696d4ea7ebf85dc19471eaf2457af65 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 14:56:15 +0100 Subject: [PATCH 02/32] Add missing golden test files for pretty-print tests The *.json gitignore rule was preventing these from being tracked. Generated from the JSON() function output to match TestJSON expectations. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- cmd/pretty/testdata/already_indented.json | 3 +++ cmd/pretty/testdata/compact_array.json | 8 ++++++++ cmd/pretty/testdata/compact_object.json | 4 ++++ cmd/pretty/testdata/empty_array.json | 1 + cmd/pretty/testdata/empty_object.json | 1 + cmd/pretty/testdata/nested_objects.json | 7 +++++++ cmd/pretty/testdata/null_and_bool.json | 5 +++++ cmd/pretty/testdata/special_chars.json | 6 ++++++ cmd/pretty/testdata/unicode.json | 5 +++++ 9 files changed, 40 insertions(+) create mode 100644 cmd/pretty/testdata/already_indented.json create mode 100644 cmd/pretty/testdata/compact_array.json create mode 100644 cmd/pretty/testdata/compact_object.json create mode 100644 cmd/pretty/testdata/empty_array.json create mode 100644 cmd/pretty/testdata/empty_object.json create mode 100644 cmd/pretty/testdata/nested_objects.json create mode 100644 cmd/pretty/testdata/null_and_bool.json create mode 100644 cmd/pretty/testdata/special_chars.json create mode 100644 cmd/pretty/testdata/unicode.json diff --git a/cmd/pretty/testdata/already_indented.json b/cmd/pretty/testdata/already_indented.json new file mode 100644 index 0000000..7a9e864 --- /dev/null +++ b/cmd/pretty/testdata/already_indented.json @@ -0,0 +1,3 @@ +{ + "key": "value" +} diff --git a/cmd/pretty/testdata/compact_array.json b/cmd/pretty/testdata/compact_array.json new file mode 100644 index 0000000..3a11aa1 --- /dev/null +++ b/cmd/pretty/testdata/compact_array.json @@ -0,0 +1,8 @@ +[ + { + "id": 1 + }, + { + "id": 2 + } +] diff --git a/cmd/pretty/testdata/compact_object.json b/cmd/pretty/testdata/compact_object.json new file mode 100644 index 0000000..60dffeb --- /dev/null +++ b/cmd/pretty/testdata/compact_object.json @@ -0,0 +1,4 @@ +{ + "name": "John", + "age": 30 +} diff --git a/cmd/pretty/testdata/empty_array.json b/cmd/pretty/testdata/empty_array.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/cmd/pretty/testdata/empty_array.json @@ -0,0 +1 @@ +[] diff --git a/cmd/pretty/testdata/empty_object.json b/cmd/pretty/testdata/empty_object.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/cmd/pretty/testdata/empty_object.json @@ -0,0 +1 @@ +{} diff --git a/cmd/pretty/testdata/nested_objects.json b/cmd/pretty/testdata/nested_objects.json new file mode 100644 index 0000000..b2a72ab --- /dev/null +++ b/cmd/pretty/testdata/nested_objects.json @@ -0,0 +1,7 @@ +{ + "a": { + "b": { + "c": 1 + } + } +} diff --git a/cmd/pretty/testdata/null_and_bool.json b/cmd/pretty/testdata/null_and_bool.json new file mode 100644 index 0000000..a28ba15 --- /dev/null +++ b/cmd/pretty/testdata/null_and_bool.json @@ -0,0 +1,5 @@ +{ + "value": null, + "enabled": true, + "disabled": false +} diff --git a/cmd/pretty/testdata/special_chars.json b/cmd/pretty/testdata/special_chars.json new file mode 100644 index 0000000..754a0c9 --- /dev/null +++ b/cmd/pretty/testdata/special_chars.json @@ -0,0 +1,6 @@ +{ + "html": "\u003cscript\u003ealert('xss')\u003c/script\u003e", + "newlines": "line1\nline2", + "tabs": "col1\tcol2", + "quotes": "she said \"hello\"" +} diff --git a/cmd/pretty/testdata/unicode.json b/cmd/pretty/testdata/unicode.json new file mode 100644 index 0000000..49a6d23 --- /dev/null +++ b/cmd/pretty/testdata/unicode.json @@ -0,0 +1,5 @@ +{ + "greeting": "こんにちは", + "emoji": "🎉", + "accented": "café" +} From 9150ded773d2011485f4d65ba217a8edfd5e3f84 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 14:56:22 +0100 Subject: [PATCH 03/32] Fix stale references and add missing commands in documentation Replace nonexistent login command with authorize, fix IMS_ suffix to prefix, remove ghost completion section, add missing commands (pkce, client, decode, refresh, admin, dcr), fix grammar and heading consistency. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- DOCUMENTATION.md | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index a5d8b6d..5f9ac45 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -12,31 +12,31 @@ The command will return 0 in case of success or 1 in case of an error. ## Subcommands ### Authorize -imscli login will negotiate an ***access token*** with IMS following the specified ***flow***. +imscli authorize will negotiate an ***access token*** with IMS following the specified ***flow***. #### imscli authorize user (standard Authorization Code Grant Flow) -This command will launch a browser and execute the normal OAuth2 flow done by users when log into IMS to use a service. +This command will launch a browser and execute the normal OAuth2 flow done by users when logging into IMS to use a service. -#### imscli authz service (an IMS specific flow similar to the Client Credentials Grant Flow). +#### imscli authorize service (an IMS specific flow similar to the Client Credentials Grant Flow). The imscli client will exchange client credentials and an additional service token to obtain the access token. It is used to access an "Application", an Adobe API exposed through Adobe I/O Gateway. -#### imscli authz jwt (JWT Bearer Flow). +#### imscli authorize jwt (JWT Bearer Flow). -This command will build a JWT will all specified claims, sign it with a private key and exchange it for an access token. +This command will build a JWT with all specified claims, sign it with a private key and exchange it for an access token. It is used for "Adobe I/O" integrations. -### Completion +#### imscli authorize pkce (Authorization Code Grant Flow with PKCE) -Generate a script to enable autocompletion for imscli. +Same as the user flow, but uses Proof Key for Code Exchange (PKCE). Supports public clients that cannot securely store a client secret. -To configure bash, add the following line to your .bashrc (or alternative config file): +#### imscli authorize client (Client Credentials Grant Flow) - eval "$(imscli autocompletion bash)" +Exchanges client credentials (client ID + secret) and scopes directly for an access token, without user interaction. ### Profile @@ -59,6 +59,24 @@ Validates a token using the IMS API. Invalidates a token using the IMS API. +### Decode + +Decodes a JWT token locally, printing the header and payload without contacting IMS. + +### Refresh + +Refreshes an access token using a refresh token. + +### Admin + +Administrative operations using a service token: + +- **admin profile**: Retrieve a user profile using a service token, client ID, guid and auth source. +- **admin organizations**: Retrieve organizations for a user using a service token. + +### DCR (Dynamic Client Registration) + +Register a new OAuth client using Dynamic Client Registration. ## Configuration @@ -77,9 +95,9 @@ imscli authz user --scopes AdobeID,openid,session #### Environment variables -Each parameter can be provided using the flag name and the IMS_ suffix. +Each parameter can be provided using the flag name and the IMS_ prefix. ``` -IMS_SCOPES="AdobeID,openid,session" imscli login user +IMS_SCOPES="AdobeID,openid,session" imscli authorize user ``` #### Configuration files @@ -98,6 +116,6 @@ scopes: - openid - session -user@host$ imscli login user +user@host$ imscli authorize user ``` From d04325f80c96311c4637285b0fefab6e8bab8f41 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 14:56:30 +0100 Subject: [PATCH 04/32] Improve README with badges, commands table, and quick start Add CI, Codecov, pkg.go.dev, and license badges. Replace verbose intro with tagline. Add quick start examples, full commands table, global flags table, and configuration summary. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- README.md | 83 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 5652c8a..253bacc 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,83 @@ # imscli ![CodeQL](https://github.com/adobe/imscli/workflows/CodeQL/badge.svg) +[![CI](https://github.com/adobe/imscli/actions/workflows/ci.yml/badge.svg)](https://github.com/adobe/imscli/actions/workflows/ci.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/adobe/imscli)](https://goreportcard.com/report/github.com/adobe/imscli) [![Release with GoReleaser](https://github.com/adobe/imscli/actions/workflows/main.yml/badge.svg)](https://github.com/adobe/imscli/actions/workflows/main.yml) +[![codecov](https://codecov.io/gh/adobe/imscli/branch/main/graph/badge.svg)](https://codecov.io/gh/adobe/imscli) +![Go Version](https://img.shields.io/github/go-mod/go-version/adobe/imscli) +[![Go Reference](https://pkg.go.dev/badge/github.com/adobe/imscli.svg)](https://pkg.go.dev/github.com/adobe/imscli) +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) -This project is a small CLI tool to interact with the IMS API. The goal of this project -is to provide a small tool that can be used to troubleshoot integrations with IMS. - -This project is wrapping adobe/ims-go. +A CLI tool to troubleshoot and automate Adobe IMS integrations. ## Installation -Build the CLI or download a prebuilt [release](https://github.com/adobe/imscli/releases). +### Prebuilt binaries + +Download the latest release for your platform from the [releases page](https://github.com/adobe/imscli/releases). -Example: +### From source ```sh go install github.com/adobe/imscli@latest ``` -## Usage - -Once installed, you can start reading the integrated help with the help subcommand. +## Quick Start -Examples: - -``` -imscli help +```sh +# Authorize as a user (launches browser for OAuth2 flow) +imscli authorize user --clientID --clientSecret --organization --scopes openid -imscli authorize help +# Validate a token +imscli validate accessToken --clientID --accessToken -imscli authorize user help +# Decode a JWT locally (no API call) +imscli decode --token ``` -The complete documentation of the project is available in the [DOCUMENTATION.md](DOCUMENTATION.md) file. - -## Development Notes - -### PersistentPreRunE and subcommands - -The root command defines a `PersistentPreRunE` that loads configuration from flags, environment variables, and config files (see `cmd/root.go`). In cobra, if a subcommand defines its own `PersistentPreRunE`, it **overrides** the parent's — the root's `PersistentPreRunE` will not run for that subcommand or its children. If you need to add a `PersistentPreRunE` to a subcommand, you must explicitly call the parent's first. +## Commands + +| Command | Description | +|---------|-------------| +| `authorize user` | OAuth2 Authorization Code Grant Flow (launches browser) | +| `authorize pkce` | Authorization Code Grant Flow with PKCE (public clients) | +| `authorize service` | Service authorization (client credentials + service token) | +| `authorize jwt` | JWT Bearer Flow (signed JWT exchanged for access token) | +| `authorize client` | Client Credentials Grant Flow | +| `validate` | Validate a token using the IMS API | +| `invalidate` | Invalidate a token using the IMS API | +| `decode` | Decode a JWT token locally | +| `refresh` | Refresh an access token | +| `exchange` | Cluster access token exchange across IMS Orgs | +| `profile` | Retrieve user profile | +| `organizations` | List user organizations | +| `admin` | Admin operations (profile, organizations) via service token | +| `dcr` | Dynamic Client Registration | + +See [DOCUMENTATION.md](DOCUMENTATION.md) for full details on each command. + +## Global Flags + +These flags apply to all commands: + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--url` | `-U` | `https://ims-na1.adobelogin.com` | IMS endpoint URL | +| `--proxyUrl` | `-P` | | HTTP(S) proxy (`http(s)://host:port`) | +| `--proxyIgnoreTLS` | `-T` | `false` | Skip TLS verification (proxy only) | +| `--configFile` | `-f` | | Configuration file path | +| `--timeout` | | `30` | HTTP client timeout in seconds | +| `--verbose` | `-v` | `false` | Verbose output | + +## Configuration + +Parameters can be provided from three sources (highest to lowest priority): + +1. **CLI flags** — `imscli authorize user --scopes openid` +2. **Environment variables** — `IMS_SCOPES=openid imscli authorize user` +3. **Configuration file** — `~/.config/imscli.yaml` or specified with `-f` + +See [DOCUMENTATION.md](DOCUMENTATION.md) for configuration file format and examples. ## Contributing From b1cec9b5587701e56897dedcafd6d1bd71a5e561 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 14:56:38 +0100 Subject: [PATCH 05/32] Add development setup, CI pipeline docs, and repo settings to contributing guide Add Development section with build/test/vet commands. Document all CI pipelines (ci, pr-title, release, codeql) with triggers. Document repo settings (squash merge, auto-delete). Fix gorelease typo and --rm-dist flag. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- CONTRIBUTING.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ba3aff..fa69b69 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,76 @@ # Contributing -## Release process + +This project wraps [adobe/ims-go](https://github.com/adobe/ims-go). + +## Development + +```bash +# Build +go build -o imscli . + +# Run all tests +go test ./... + +# Run a single test by name +go test ./ims/ -run TestValidateURL + +# Vet (static analysis) +go vet ./... +``` + +## CI Pipelines + +All pipelines are defined in `.github/workflows/`. + +### CI (`ci.yml`) + +**Triggers:** Every push to any branch and pull requests targeting `main`. + +Runs three parallel jobs: + +- **Test** — Runs `go test -race` with coverage and uploads the report to Codecov. +- **Lint** — Runs `go vet` and [golangci-lint](https://golangci-lint.run/) for extended static analysis. +- **Build** — Verifies the project compiles. Cross-platform builds are handled by GoReleaser at release time, so a single-platform check suffices here. + +### PR Title (`pr-title.yml`) + +**Triggers:** When a pull request is opened, edited, synchronized or reopened. + +Validates that the PR title follows the [Conventional Commits](https://www.conventionalcommits.org) format (e.g. `feat: add token refresh`, `fix(auth): handle expired tokens`). This is enforced because the repository is configured for **squash merging only** — the PR title becomes the commit message on `main`, and GoReleaser uses these prefixes to group the release changelog into Features, Bug Fixes, etc. + +### Release (`main.yml`) + +**Triggers:** Pushing a version tag (e.g. `v1.2.3`) or manual dispatch from the Actions tab. + +Runs [GoReleaser](https://goreleaser.com) to build cross-platform binaries (linux/darwin/windows × amd64/arm64), package them as archives and system packages (deb, rpm, apk), generate a changelog grouped by commit type, and publish a GitHub Release with all artifacts. + +### CodeQL (`codeql-analysis.yml`) + +**Triggers:** Every push, pull requests targeting `main`, and weekly on a cron schedule. + +Runs GitHub's CodeQL security analysis to detect vulnerabilities in the Go source code. + +## Repository Settings + +- **Squash merge only** — Merge commits and rebase merging are disabled. The PR title is used as the squash commit message, ensuring conventional commit messages land on `main`. +- **Auto-delete branches** — Head branches are automatically deleted after a PR is merged. + +## Release Process In order to standardize the release process, [goreleaser](https://goreleaser.com) has been adopted. To build and release a new version: ``` git tag vX.X.X && git push --tags -gorelease --rm-dist +goreleaser release --clean ``` The binary version is set automatically to the git tag. Please tag new versions using [semantic versioning](https://semver.org/spec/v2.0.0.html). + +## Development Notes + +### PersistentPreRunE and subcommands + +The root command defines a `PersistentPreRunE` that loads configuration from flags, environment variables, and config files (see `cmd/root.go`). In cobra, if a subcommand defines its own `PersistentPreRunE`, it **overrides** the parent's — the root's `PersistentPreRunE` will not run for that subcommand or its children. If you need to add a `PersistentPreRunE` to a subcommand, you must explicitly call the parent's first. From 576d4cc5202961f53d5fa594b0c12ac77c38cae4 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 14:56:46 +0100 Subject: [PATCH 06/32] Add CI and PR title validation workflows CI workflow runs test (with coverage upload to Codecov), lint (go vet + golangci-lint v2), and build jobs on every push and PR to main. PR title workflow enforces conventional commit format on PR titles to support squash-merge changelog generation. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .github/workflows/ci.yml | 78 ++++++++++++++++++++++++++++++++++ .github/workflows/pr-title.yml | 41 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-title.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..effe32a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +# Run on every push and on pull requests targeting main. +on: + push: + branches: ['**'] + pull_request: + branches: [main] + +# Read-only access is sufficient for running tests and linting. +permissions: + contents: read + +jobs: + # Run the full test suite with race detection and generate a coverage report. + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + # Go version is read from go.mod so it stays in sync with the project. + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Run tests + run: go test -race -coverprofile=coverage.out -covermode=atomic ./... + + # Upload the coverage report to Codecov. Non-blocking so CI still + # passes if Codecov is temporarily unavailable. + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: coverage.out + fail_ci_if_error: false + + # Static analysis: go vet for built-in checks, golangci-lint for extended linting. + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Run go vet + run: go vet ./... + + # golangci-lint v2 runs 50+ linters in a single pass. + # Without a .golangci.yml config file it uses sensible defaults. + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.10.1 + + # Verify the project compiles. Cross-platform builds are handled by + # GoReleaser at release time, so a single-platform check suffices here. + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Build + run: go build -o /dev/null . diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..06a7064 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,41 @@ +# Ensure PR titles follow the Conventional Commits format so that +# squash-merged commits produce a clean changelog via GoReleaser. +# See: https://www.conventionalcommits.org +name: PR Title + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + lint: + name: Validate PR title + runs-on: ubuntu-latest + steps: + # Validates that the PR title matches the conventional commit format. + # Examples of valid titles: + # feat: add token refresh command + # fix(auth): handle expired tokens + # docs: update CONTRIBUTING.md + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Types allowed in PR titles. These map to GoReleaser changelog groups. + types: | + feat + fix + docs + chore + ci + test + refactor + perf + style + build + revert + # Require a scope in parentheses (optional — set to true to enforce). + requireScope: false From 827460ea487dcbc7c625629c13dbc759ca793870 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 14:56:54 +0100 Subject: [PATCH 07/32] Clean up release workflow and add changelog grouping Remove boilerplate comments and add meaningful ones explaining each step. Switch from hardcoded go-version to go-version-file for automatic sync. Add conventional commit changelog grouping (Features, Bug Fixes, Other) and exclude ci/chore prefixes. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .github/workflows/main.yml | 26 ++++++++++---------------- .goreleaser.yml | 12 ++++++++++++ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ddcfd96..7634b94 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,39 +1,34 @@ -# This is a basic workflow to help you get started with Actions - name: Release with GoReleaser -# Controls when the action will run. +# Triggered by pushing a version tag (e.g. v1.2.3) or manually via the Actions tab. on: - # Triggers the release workflow only for new tags push: tags: - '*' - - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# A workflow run is made up of one or more jobs that can run sequentially or in parallel +# GoReleaser needs write access to create the GitHub release and upload artifacts. +permissions: + contents: write + jobs: - # This workflow contains a single job called "build" release: - # The type of runner that the job will run on runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + # Full history is required for GoReleaser to generate the changelog. - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - # Runs a single command using the runners shell + # Go version is read from go.mod so it stays in sync with the project. - name: Set up Go uses: actions/setup-go@v6 with: - go-version: 1.26 + go-version: 'stable' - # Runs a set of commands using the runners shell + # GoReleaser builds, packages and publishes the release. + # See .goreleaser.yml for build targets and packaging configuration. - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: @@ -41,4 +36,3 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.goreleaser.yml b/.goreleaser.yml index 8bfdc52..41c7072 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -22,12 +22,24 @@ checksum: name_template: 'checksums.txt' snapshot: version_template: "{{ .Version }}-next" +# Group commits by conventional-commit prefix for cleaner release notes. changelog: sort: asc + groups: + - title: Features + regexp: '^.*?feat(\([^)]+\))?!?:.+$' + order: 0 + - title: Bug Fixes + regexp: '^.*?fix(\([^)]+\))?!?:.+$' + order: 1 + - title: Other + order: 999 filters: exclude: - '^docs:' - '^test:' + - '^ci:' + - '^chore:' nfpms: - file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' homepage: "https://github.com/adobe/imscli" From 3faa3b8781941c236569861538b5873fb8a6a926 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 14:57:02 +0100 Subject: [PATCH 08/32] Fix unchecked error returns flagged by linter Explicitly discard error returns from fmt.Fprintln in HTTP handlers (connection already dead if write fails), listener.Close and res.Body.Close in deferred calls (nothing actionable on failure). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- ims/authz_user.go | 6 +++--- ims/register.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ims/authz_user.go b/ims/authz_user.go index 07685e2..452e946 100644 --- a/ims/authz_user.go +++ b/ims/authz_user.go @@ -85,13 +85,13 @@ func (i Config) authorizeUser(pkce bool) (string, error) { UsePKCE: pkce, RedirectURI: fmt.Sprintf("http://localhost:%d", i.Port), OnError: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, ` + _, _ = fmt.Fprintln(w, `

An error occurred

Please look at the terminal output for further details.

`) }), OnSuccess: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, ` + _, _ = fmt.Fprintln(w, `

Login successful!

You can close this tab.

`) @@ -105,7 +105,7 @@ func (i Config) authorizeUser(pkce bool) (string, error) { if err != nil { return "", fmt.Errorf("unable to listen at port %d", i.Port) } - defer listener.Close() + defer func() { _ = listener.Close() }() log.Println("Local server successfully launched and contacted.") diff --git a/ims/register.go b/ims/register.go index 7f70e8c..1590231 100644 --- a/ims/register.go +++ b/ims/register.go @@ -59,7 +59,7 @@ func (i Config) Register() (RegisterResponse, error) { if err != nil { return RegisterResponse{}, fmt.Errorf("error making registration request: %v", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(res.Body) if err != nil { From 37d0923008d077baff1bc62c5cb1f7cc521eb566 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 15:39:25 +0100 Subject: [PATCH 09/32] Clarify PKCE usage for public and private clients PKCE is mandatory for public clients but optional (recommended) for private clients in IMS. The previous docs implied PKCE was only for public clients. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- DOCUMENTATION.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 5f9ac45..58e7774 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -32,7 +32,7 @@ It is used for "Adobe I/O" integrations. #### imscli authorize pkce (Authorization Code Grant Flow with PKCE) -Same as the user flow, but uses Proof Key for Code Exchange (PKCE). Supports public clients that cannot securely store a client secret. +Like the user command, it uses the Authorization Code Grant Flow but with Proof Key for Code Exchange (PKCE). In IMS, PKCE is mandatory for public clients and recommended for private clients. #### imscli authorize client (Client Credentials Grant Flow) diff --git a/README.md b/README.md index 253bacc..11b39fd 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ imscli decode --token | Command | Description | |---------|-------------| | `authorize user` | OAuth2 Authorization Code Grant Flow (launches browser) | -| `authorize pkce` | Authorization Code Grant Flow with PKCE (public clients) | +| `authorize pkce` | Authorization Code Grant Flow with PKCE (mandatory for public clients, optional for private) | | `authorize service` | Service authorization (client credentials + service token) | | `authorize jwt` | JWT Bearer Flow (signed JWT exchanged for access token) | | `authorize client` | Client Credentials Grant Flow | From 785da2ca42397212d88d1e092eab89039fb0d5e4 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 15:45:11 +0100 Subject: [PATCH 10/32] Replace Codecov with coverage summary in CI log Codecov requires admin access to install the GitHub App. Replace with a go tool cover summary printed to the CI log. Remove the Codecov badge from README. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .github/workflows/ci.yml | 10 +++------- README.md | 1 - 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index effe32a..cefbfb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,13 +29,9 @@ jobs: - name: Run tests run: go test -race -coverprofile=coverage.out -covermode=atomic ./... - # Upload the coverage report to Codecov. Non-blocking so CI still - # passes if Codecov is temporarily unavailable. - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - files: coverage.out - fail_ci_if_error: false + # Print coverage summary to the CI log. + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -1 # Static analysis: go vet for built-in checks, golangci-lint for extended linting. lint: diff --git a/README.md b/README.md index 11b39fd..d7562e4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ [![CI](https://github.com/adobe/imscli/actions/workflows/ci.yml/badge.svg)](https://github.com/adobe/imscli/actions/workflows/ci.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/adobe/imscli)](https://goreportcard.com/report/github.com/adobe/imscli) [![Release with GoReleaser](https://github.com/adobe/imscli/actions/workflows/main.yml/badge.svg)](https://github.com/adobe/imscli/actions/workflows/main.yml) -[![codecov](https://codecov.io/gh/adobe/imscli/branch/main/graph/badge.svg)](https://codecov.io/gh/adobe/imscli) ![Go Version](https://img.shields.io/github/go-mod/go-version/adobe/imscli) [![Go Reference](https://pkg.go.dev/badge/github.com/adobe/imscli.svg)](https://pkg.go.dev/github.com/adobe/imscli) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) From e77f0a8fc5371e4e2cb939ae83517fad6f2adb27 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 16:08:34 +0100 Subject: [PATCH 11/32] Add govulncheck workflow for dependency vulnerability scanning --- .github/workflows/govulncheck.yml | 29 +++++++++++++++++++++++++++++ CONTRIBUTING.md | 6 ++++++ README.md | 1 + 3 files changed, 36 insertions(+) create mode 100644 .github/workflows/govulncheck.yml diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 0000000..8c6e528 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,29 @@ +# Runs Go's official vulnerability scanner against project dependencies. +# Fails the job if any known vulnerabilities affect the code. +# See https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck + +name: govulncheck + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + # golang/govulncheck-action runs govulncheck against all packages (./...). + # With the default text output format, the job fails if vulnerabilities are found. + - uses: golang/govulncheck-action@v1 + with: + go-version-file: go.mod diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa69b69..a263c97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,12 @@ Runs [GoReleaser](https://goreleaser.com) to build cross-platform binaries (linu Runs GitHub's CodeQL security analysis to detect vulnerabilities in the Go source code. +### govulncheck (`govulncheck.yml`) + +**Triggers:** Every push to `main` and pull requests. + +Runs Go's official vulnerability scanner ([govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck)) against all packages. The job fails if any known vulnerabilities in the Go vulnerability database affect the code. Unlike general-purpose scanners, govulncheck traces call graphs — it only reports vulnerabilities in functions your code actually calls. + ## Repository Settings - **Squash merge only** — Merge commits and rebase merging are disabled. The PR title is used as the squash commit message, ensuring conventional commit messages land on `main`. diff --git a/README.md b/README.md index d7562e4..0693334 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # imscli ![CodeQL](https://github.com/adobe/imscli/workflows/CodeQL/badge.svg) +[![govulncheck](https://github.com/adobe/imscli/actions/workflows/govulncheck.yml/badge.svg)](https://github.com/adobe/imscli/actions/workflows/govulncheck.yml) [![CI](https://github.com/adobe/imscli/actions/workflows/ci.yml/badge.svg)](https://github.com/adobe/imscli/actions/workflows/ci.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/adobe/imscli)](https://goreportcard.com/report/github.com/adobe/imscli) [![Release with GoReleaser](https://github.com/adobe/imscli/actions/workflows/main.yml/badge.svg)](https://github.com/adobe/imscli/actions/workflows/main.yml) From 0f01bbabdff96831eb4eb89386aa26af11983509 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 16:09:41 +0100 Subject: [PATCH 12/32] Link docs/ write-ups from CONTRIBUTING.md --- CONTRIBUTING.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a263c97..eb12e9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,3 +80,10 @@ Please tag new versions using [semantic versioning](https://semver.org/spec/v2.0 ### PersistentPreRunE and subcommands The root command defines a `PersistentPreRunE` that loads configuration from flags, environment variables, and config files (see `cmd/root.go`). In cobra, if a subcommand defines its own `PersistentPreRunE`, it **overrides** the parent's — the root's `PersistentPreRunE` will not run for that subcommand or its children. If you need to add a `PersistentPreRunE` to a subcommand, you must explicitly call the parent's first. + +## Additional Reading + +The `docs/` directory contains write-ups on non-obvious problems encountered during development: + +- [OAuth Local Server: Unhandled Serve() Error](docs/oauth-serve-error.md) — Why the `Serve` goroutine error is captured via a buffered channel, and why the channel must be buffered. +- [OAuth Local Server Shutdown Deadlock](docs/oauth-shutdown-deadlock.md) — How an unbuffered channel creates a deadlock between the HTTP handler and `Shutdown()`, and the two-part fix. From b75534b05f428213648d12c1e13a9d9b6a7c41cd Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 16:12:33 +0100 Subject: [PATCH 13/32] Enable Renovate auto-merge for patch dependency updates --- CONTRIBUTING.md | 1 + renovate.json | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb12e9a..ba07257 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,7 @@ Runs Go's official vulnerability scanner ([govulncheck](https://pkg.go.dev/golan - **Squash merge only** — Merge commits and rebase merging are disabled. The PR title is used as the squash commit message, ensuring conventional commit messages land on `main`. - **Auto-delete branches** — Head branches are automatically deleted after a PR is merged. +- **Renovate auto-merge** — [Renovate](https://docs.renovatebot.com/) monitors `go.mod` for dependency updates and opens PRs automatically. Patch updates (e.g., `v1.8.0` → `v1.8.1`) are auto-merged after CI passes. Minor and major updates require manual review. Configuration lives in `renovate.json`. ## Release Process diff --git a/renovate.json b/renovate.json index c3bdc63..35384c9 100644 --- a/renovate.json +++ b/renovate.json @@ -6,5 +6,12 @@ "postUpdateOptions": [ "gomodTidy", "gomodUpdateImportPaths" + ], + "packageRules": [ + { + "description": "Auto-merge patch updates after CI passes", + "matchUpdateTypes": ["patch"], + "automerge": true + } ] } From ead6e1c78c979a18b883cdb32f04e4ce4e71f939 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 16:35:28 +0100 Subject: [PATCH 14/32] Add weekly govulncheck schedule with auto-issue on failure --- .github/workflows/govulncheck.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 8c6e528..51df0dc 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -8,9 +8,13 @@ on: push: branches: [main] pull_request: + # Weekly scan catches new vulnerabilities even when the code hasn't changed. + schedule: + - cron: '0 9 * * 1' # Every Monday at 9:00 UTC permissions: contents: read + issues: write jobs: govulncheck: @@ -27,3 +31,25 @@ jobs: - uses: golang/govulncheck-action@v1 with: go-version-file: go.mod + + # Open a GitHub issue when the scheduled scan finds vulnerabilities. + # Only runs on cron failures — PR and push failures are visible in the checks UI. + notify: + needs: govulncheck + runs-on: ubuntu-latest + if: failure() && github.event_name == 'schedule' + steps: + - uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'govulncheck: vulnerability detected', + body: [ + 'The weekly [govulncheck scan](' + context.serverUrl + '/' + context.repo.owner + '/' + context.repo.repo + '/actions/runs/' + context.runId + ') found vulnerabilities.', + '', + 'Review the workflow run and update affected dependencies.' + ].join('\n'), + labels: ['security'] + }); From 53b7e572ab047ce16ea98a6a5cdf8c7e7592166f Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 16:37:28 +0100 Subject: [PATCH 15/32] Add go mod verify and goreleaser check to CI --- .github/workflows/ci.yml | 10 ++++++++++ CONTRIBUTING.md | 8 ++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cefbfb6..54897f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,10 @@ jobs: with: go-version-file: go.mod + # Verify dependency checksums match go.sum (detects corrupted or tampered modules). + - name: Verify dependencies + run: go mod verify + - name: Run tests run: go test -race -coverprofile=coverage.out -covermode=atomic ./... @@ -72,3 +76,9 @@ jobs: - name: Build run: go build -o /dev/null . + + # Validate .goreleaser.yml so config errors are caught before tagging a release. + - name: Verify GoReleaser config + uses: goreleaser/goreleaser-action@v6 + with: + args: check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba07257..3aedfd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,9 +28,9 @@ All pipelines are defined in `.github/workflows/`. Runs three parallel jobs: -- **Test** — Runs `go test -race` with coverage and uploads the report to Codecov. +- **Test** — Runs `go test -race` with coverage and prints a coverage summary to the log. Verifies dependency checksums with `go mod verify`. - **Lint** — Runs `go vet` and [golangci-lint](https://golangci-lint.run/) for extended static analysis. -- **Build** — Verifies the project compiles. Cross-platform builds are handled by GoReleaser at release time, so a single-platform check suffices here. +- **Build** — Verifies the project compiles and validates `.goreleaser.yml` with `goreleaser check`. Cross-platform builds are handled by GoReleaser at release time, so a single-platform check suffices here. ### PR Title (`pr-title.yml`) @@ -52,9 +52,9 @@ Runs GitHub's CodeQL security analysis to detect vulnerabilities in the Go sourc ### govulncheck (`govulncheck.yml`) -**Triggers:** Every push to `main` and pull requests. +**Triggers:** Every push to `main`, pull requests, and weekly on Monday at 9:00 UTC. -Runs Go's official vulnerability scanner ([govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck)) against all packages. The job fails if any known vulnerabilities in the Go vulnerability database affect the code. Unlike general-purpose scanners, govulncheck traces call graphs — it only reports vulnerabilities in functions your code actually calls. +Runs Go's official vulnerability scanner ([govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck)) against all packages. The job fails if any known vulnerabilities in the Go vulnerability database affect the code. Unlike general-purpose scanners, govulncheck traces call graphs — it only reports vulnerabilities in functions your code actually calls. The weekly schedule catches new vulnerabilities even when the code hasn't changed. If the scheduled scan fails, a GitHub issue labeled `security` is created automatically. ## Repository Settings From 916f7111768813fc8b49ad46a809deec117567e8 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 16:46:46 +0100 Subject: [PATCH 16/32] Use go-version stable across all workflows --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/govulncheck.yml | 8 ++++---- .github/workflows/main.yml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54897f5..85680a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,11 +20,11 @@ jobs: - name: Checkout uses: actions/checkout@v6 - # Go version is read from go.mod so it stays in sync with the project. + # Always use the latest stable Go release for the newest compiler and stdlib fixes. - name: Set up Go uses: actions/setup-go@v6 with: - go-version-file: go.mod + go-version: 'stable' # Verify dependency checksums match go.sum (detects corrupted or tampered modules). - name: Verify dependencies @@ -48,7 +48,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version-file: go.mod + go-version: 'stable' - name: Run go vet run: go vet ./... @@ -72,7 +72,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version-file: go.mod + go-version: 'stable' - name: Build run: go build -o /dev/null . diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 51df0dc..e1ca98e 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -20,17 +20,17 @@ jobs: govulncheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version-file: go.mod + go-version: 'stable' # golang/govulncheck-action runs govulncheck against all packages (./...). # With the default text output format, the job fails if vulnerabilities are found. - uses: golang/govulncheck-action@v1 with: - go-version-file: go.mod + go-version-input: 'stable' # Open a GitHub issue when the scheduled scan finds vulnerabilities. # Only runs on cron failures — PR and push failures are visible in the checks UI. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7634b94..11290e8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,7 +21,7 @@ jobs: with: fetch-depth: 0 - # Go version is read from go.mod so it stays in sync with the project. + # Always use the latest stable Go release for the newest compiler and stdlib fixes. - name: Set up Go uses: actions/setup-go@v6 with: From 26032793407c568c4d529ce0b08cda7082940404 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 16:49:29 +0100 Subject: [PATCH 17/32] Revert to go-version-file go.mod for controllable Go version pinning --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/govulncheck.yml | 4 ++-- .github/workflows/main.yml | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85680a6..54897f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,11 +20,11 @@ jobs: - name: Checkout uses: actions/checkout@v6 - # Always use the latest stable Go release for the newest compiler and stdlib fixes. + # Go version is read from go.mod so it stays in sync with the project. - name: Set up Go uses: actions/setup-go@v6 with: - go-version: 'stable' + go-version-file: go.mod # Verify dependency checksums match go.sum (detects corrupted or tampered modules). - name: Verify dependencies @@ -48,7 +48,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: 'stable' + go-version-file: go.mod - name: Run go vet run: go vet ./... @@ -72,7 +72,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: 'stable' + go-version-file: go.mod - name: Build run: go build -o /dev/null . diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index e1ca98e..1af609d 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -24,13 +24,13 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: 'stable' + go-version-file: go.mod # golang/govulncheck-action runs govulncheck against all packages (./...). # With the default text output format, the job fails if vulnerabilities are found. - uses: golang/govulncheck-action@v1 with: - go-version-input: 'stable' + go-version-file: go.mod # Open a GitHub issue when the scheduled scan finds vulnerabilities. # Only runs on cron failures — PR and push failures are visible in the checks UI. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 11290e8..d57156e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,11 +21,11 @@ jobs: with: fetch-depth: 0 - # Always use the latest stable Go release for the newest compiler and stdlib fixes. + # Go version is read from go.mod so it stays in sync with the project. - name: Set up Go uses: actions/setup-go@v6 with: - go-version: 'stable' + go-version-file: go.mod # GoReleaser builds, packages and publishes the release. # See .goreleaser.yml for build targets and packaging configuration. From a9bc578d70b77d16975bc345d102a23ab2d2adeb Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 16:50:25 +0100 Subject: [PATCH 18/32] Document Go version pinning strategy in CONTRIBUTING.md --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3aedfd8..1d8f1f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,7 @@ Runs Go's official vulnerability scanner ([govulncheck](https://pkg.go.dev/golan - **Squash merge only** — Merge commits and rebase merging are disabled. The PR title is used as the squash commit message, ensuring conventional commit messages land on `main`. - **Auto-delete branches** — Head branches are automatically deleted after a PR is merged. - **Renovate auto-merge** — [Renovate](https://docs.renovatebot.com/) monitors `go.mod` for dependency updates and opens PRs automatically. Patch updates (e.g., `v1.8.0` → `v1.8.1`) are auto-merged after CI passes. Minor and major updates require manual review. Configuration lives in `renovate.json`. +- **Go version pinning** — All CI workflows use `go-version-file: go.mod` so the Go compiler version is controlled by the `toolchain` directive in `go.mod`. To upgrade Go, update `go.mod` (Renovate opens PRs for this automatically). To downgrade after a bad release, revert the `toolchain` line in `go.mod` — all workflows pick up the change immediately. ## Release Process From 1ecaa4d791a7157cce305fb0188b6c70bb484c62 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 17:56:48 +0100 Subject: [PATCH 19/32] Fix govulncheck duplicate auth header by disabling persist-credentials --- .github/workflows/govulncheck.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 1af609d..50dbdbd 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -21,6 +21,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-go@v6 with: From 27a09c9d24370a9b1705b7175accb402afc2f50f Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 17:25:11 +0100 Subject: [PATCH 20/32] Fix DCR consistency: safe JSON, proper HTTP client, error wrapping --- cmd/dcr.go | 16 +++++++++--- ims/client.go | 10 ++++++++ ims/register.go | 65 ++++++++++++++++++++++++++----------------------- 3 files changed, 57 insertions(+), 34 deletions(-) diff --git a/cmd/dcr.go b/cmd/dcr.go index 7ca9116..5bde725 100644 --- a/cmd/dcr.go +++ b/cmd/dcr.go @@ -1,8 +1,19 @@ +// Copyright 2025 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. + package cmd import ( "fmt" + "github.com/adobe/imscli/cmd/pretty" "github.com/adobe/imscli/ims" "github.com/spf13/cobra" ) @@ -30,11 +41,10 @@ func registerCmd(imsConfig *ims.Config) *cobra.Command { resp, err := imsConfig.Register() if err != nil { - return fmt.Errorf("error during client registration: %v", err) + return fmt.Errorf("error during client registration: %w", err) } - fmt.Printf("Status Code: %d\n", resp.StatusCode) - fmt.Println(resp.Body) + fmt.Println(pretty.JSON(resp)) return nil }, } diff --git a/ims/client.go b/ims/client.go index 5ab17f8..b99bacd 100644 --- a/ims/client.go +++ b/ims/client.go @@ -1,3 +1,13 @@ +// Copyright 2020 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. + package ims import ( diff --git a/ims/register.go b/ims/register.go index 1590231..d35c3d6 100644 --- a/ims/register.go +++ b/ims/register.go @@ -1,22 +1,29 @@ +// Copyright 2025 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. + package ims import ( + "encoding/json" "fmt" "io" "net/http" "strings" ) -// RegisterResponse contains the response from DCR registration -type RegisterResponse struct { - StatusCode int - Body string -} - func (i Config) validateRegisterConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientName == "": return fmt.Errorf("missing client name parameter") case len(i.RedirectURIs) == 0: @@ -26,48 +33,44 @@ func (i Config) validateRegisterConfig() error { } } -// Register performs Dynamic Client Registration -func (i Config) Register() (RegisterResponse, error) { +// Register performs Dynamic Client Registration. +func (i Config) Register() (string, error) { if err := i.validateRegisterConfig(); err != nil { - return RegisterResponse{}, fmt.Errorf("invalid parameters for client registration: %v", err) + return "", fmt.Errorf("invalid parameters for client registration: %w", err) } - // Build redirect URIs JSON array - redirectURIsJSON := "[" - for idx, uri := range i.RedirectURIs { - if idx > 0 { - redirectURIsJSON += "," - } - redirectURIsJSON += fmt.Sprintf(`"%s"`, uri) + // Build the request payload using json.Marshal for proper escaping. + payload, err := json.Marshal(map[string]interface{}{ + "client_name": i.ClientName, + "redirect_uris": i.RedirectURIs, + }) + if err != nil { + return "", fmt.Errorf("error building registration payload: %w", err) } - redirectURIsJSON += "]" - - payload := strings.NewReader(fmt.Sprintf(`{ - "client_name": "%s", - "redirect_uris": %s -}`, i.ClientName, redirectURIsJSON)) endpoint := strings.TrimRight(i.URL, "/") + "/ims/register" - req, err := http.NewRequest("POST", endpoint, payload) + req, err := http.NewRequest("POST", endpoint, strings.NewReader(string(payload))) if err != nil { - return RegisterResponse{}, fmt.Errorf("error creating request: %v", err) + return "", fmt.Errorf("error creating request: %w", err) } req.Header.Add("content-type", "application/json") - res, err := http.DefaultClient.Do(req) + httpClient, err := i.httpClient() + if err != nil { + return "", fmt.Errorf("error creating the HTTP client: %w", err) + } + + res, err := httpClient.Do(req) if err != nil { - return RegisterResponse{}, fmt.Errorf("error making registration request: %v", err) + return "", fmt.Errorf("error making registration request: %w", err) } defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(res.Body) if err != nil { - return RegisterResponse{}, fmt.Errorf("error reading response body: %v", err) + return "", fmt.Errorf("error reading response body: %w", err) } - return RegisterResponse{ - StatusCode: res.StatusCode, - Body: string(body), - }, nil + return string(body), nil } From 7013cdfbe6f8ffe600ac339f5a450ab0cf287ae0 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 17:49:44 +0100 Subject: [PATCH 21/32] Add shell completion, verbose token expiration, and fuzz tests --- cmd/completion.go | 55 +++++++++++++++++++++++ cmd/decode.go | 35 ++++++++++++++- cmd/pretty/json_test.go | 55 +++++++++++++++++++++++ cmd/root.go | 2 + ims/config.go | 1 + ims/config_test.go | 97 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 cmd/completion.go diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..5cd2447 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,55 @@ +// Copyright 2025 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. + +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +func completionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion script.", + Long: `Generate a shell completion script for the specified shell. + + # Bash + source <(imscli completion bash) + + # Zsh (add to ~/.zshrc or run once) + imscli completion zsh > "${fpath[1]}/_imscli" + + # Fish + imscli completion fish | source + + # PowerShell + imscli completion powershell | Out-String | Invoke-Expression +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletionV2(os.Stdout, true) + case "zsh": + return cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + return cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + return cmd.Root().GenPowerShellCompletion(os.Stdout) + } + return nil + }, + } + return cmd +} diff --git a/cmd/decode.go b/cmd/decode.go index a5a0a3b..3ba559a 100644 --- a/cmd/decode.go +++ b/cmd/decode.go @@ -11,7 +11,10 @@ package cmd import ( + "encoding/json" "fmt" + "os" + "time" "github.com/adobe/imscli/cmd/pretty" "github.com/adobe/imscli/ims" @@ -27,7 +30,6 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - decoded, err := imsConfig.DecodeToken() if err != nil { return fmt.Errorf("error decoding the token: %w", err) @@ -36,6 +38,12 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command { output := fmt.Sprintf(`{"header":%s,"payload":%s}`, decoded.Header, decoded.Payload) fmt.Println(pretty.JSON(output)) + // When verbose, show human-readable token expiration on stderr + // so it doesn't pollute the JSON output on stdout. + if imsConfig.Verbose { + printTokenExpiration(decoded.Payload) + } + return nil }, } @@ -44,3 +52,28 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command { return cmd } + +// printTokenExpiration parses the "exp" claim from a JWT payload and prints +// a human-readable expiration message to stderr. +func printTokenExpiration(payload string) { + var claims map[string]interface{} + if err := json.Unmarshal([]byte(payload), &claims); err != nil { + return + } + + exp, ok := claims["exp"].(float64) + if !ok { + return + } + + expTime := time.Unix(int64(exp), 0).UTC() + now := time.Now().UTC() + + if now.After(expTime) { + fmt.Fprintf(os.Stderr, "\nToken expired: %s (%s ago)\n", + expTime.Format(time.RFC3339), now.Sub(expTime).Truncate(time.Second)) + } else { + fmt.Fprintf(os.Stderr, "\nToken expires: %s (in %s)\n", + expTime.Format(time.RFC3339), expTime.Sub(now).Truncate(time.Second)) + } +} diff --git a/cmd/pretty/json_test.go b/cmd/pretty/json_test.go index 676ceab..e534798 100644 --- a/cmd/pretty/json_test.go +++ b/cmd/pretty/json_test.go @@ -11,10 +11,12 @@ package pretty import ( + "math/rand" "os" "path/filepath" "strings" "testing" + "time" ) func loadExpected(t *testing.T, filename string) string { @@ -55,3 +57,56 @@ func TestJSON(t *testing.T) { }) } } + +// randomString generates a string of the given length with arbitrary bytes, +// including invalid UTF-8, control characters, and JSON-significant characters. +// Used by the fuzz tests below to verify that JSON never panics. +func randomString(rng *rand.Rand, length int) string { + b := make([]byte, length) + for i := range b { + b[i] = byte(rng.Intn(256)) + } + return string(b) +} + +// TestFuzzJSON generates random inputs for 10 seconds to verify that JSON +// never panics regardless of input. Runs in parallel with other tests. +// +// For deeper exploration, use Go's built-in fuzz engine: +// +// go test -fuzz=FuzzJSON -fuzztime=60s ./cmd/pretty/ +func TestFuzzJSON(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + deadline := time.After(10 * time.Second) + iterations := 0 + + for { + select { + case <-deadline: + t.Logf("fuzz: %d iterations without panic", iterations) + return + default: + input := randomString(rng, rng.Intn(1024)) + _ = JSON(input) + iterations++ + } + } +} + +// FuzzJSON is a standard Go fuzz target for deeper exploration. +// Run manually: go test -fuzz=FuzzJSON -fuzztime=60s ./cmd/pretty/ +func FuzzJSON(f *testing.F) { + f.Add(`{}`) + f.Add(`[]`) + f.Add(`{"a":1}`) + f.Add(`"plain string"`) + f.Add(`null`) + f.Add(`not json`) + f.Add(``) + + f.Fuzz(func(t *testing.T, input string) { + _ = JSON(input) + }) +} diff --git a/cmd/root.go b/cmd/root.go index d394265..483c24a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,6 +34,7 @@ func RootCmd(version string) *cobra.Command { log.SetOutput(io.Discard) } // This call of the initParams will load all env vars, config file and flags. + imsConfig.Verbose = verbose return initParams(cmd, imsConfig, configFile) }, } @@ -58,6 +59,7 @@ func RootCmd(version string) *cobra.Command { refreshCmd(imsConfig), adminCmd(imsConfig), dcrCmd(imsConfig), + completionCmd(), ) return cmd } diff --git a/ims/config.go b/ims/config.go index 8500eca..2de3a75 100644 --- a/ims/config.go +++ b/ims/config.go @@ -42,6 +42,7 @@ type Config struct { Token string Port int FullOutput bool + Verbose bool Guid string AuthSrc string DecodeFulfillableData bool diff --git a/ims/config_test.go b/ims/config_test.go index c628844..df7ae90 100644 --- a/ims/config_test.go +++ b/ims/config_test.go @@ -11,7 +11,9 @@ package ims import ( + "math/rand" "testing" + "time" ) func TestValidateDecodeTokenConfig(t *testing.T) { @@ -393,3 +395,98 @@ func searchString(s, substr string) bool { } return false } + +// randomString generates a string of the given length with arbitrary bytes. +func randomString(rng *rand.Rand, length int) string { + b := make([]byte, length) + for i := range b { + b[i] = byte(rng.Intn(256)) + } + return string(b) +} + +// TestFuzzValidateURL generates random inputs for 10 seconds to verify that +// validateURL never panics regardless of input. Runs in parallel with other tests. +// +// For deeper exploration, use Go's built-in fuzz engine: +// +// go test -fuzz=FuzzValidateURL -fuzztime=60s ./ims/ +func TestFuzzValidateURL(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + deadline := time.After(10 * time.Second) + iterations := 0 + + for { + select { + case <-deadline: + t.Logf("fuzz: %d iterations without panic", iterations) + return + default: + input := randomString(rng, rng.Intn(512)) + _ = validateURL(input) + iterations++ + } + } +} + +// FuzzValidateURL is a standard Go fuzz target for deeper exploration. +// Run manually: go test -fuzz=FuzzValidateURL -fuzztime=60s ./ims/ +func FuzzValidateURL(f *testing.F) { + f.Add("https://example.com") + f.Add("http://localhost:8080") + f.Add("") + f.Add("not-a-url") + f.Add("://missing-scheme.com") + f.Add("https://") + + f.Fuzz(func(t *testing.T, u string) { + _ = validateURL(u) + }) +} + +// TestFuzzDecodeToken generates random inputs for 10 seconds to verify that +// DecodeToken never panics regardless of input. Runs in parallel with other tests. +// +// For deeper exploration, use Go's built-in fuzz engine: +// +// go test -fuzz=FuzzDecodeToken -fuzztime=60s ./ims/ +func TestFuzzDecodeToken(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + deadline := time.After(10 * time.Second) + iterations := 0 + + for { + select { + case <-deadline: + t.Logf("fuzz: %d iterations without panic", iterations) + return + default: + // Generate random JWT-like strings (three dot-separated parts) + input := randomString(rng, rng.Intn(128)) + "." + + randomString(rng, rng.Intn(256)) + "." + + randomString(rng, rng.Intn(128)) + c := Config{Token: input} + _, _ = c.DecodeToken() + iterations++ + } + } +} + +// FuzzDecodeToken is a standard Go fuzz target for deeper exploration. +// Run manually: go test -fuzz=FuzzDecodeToken -fuzztime=60s ./ims/ +func FuzzDecodeToken(f *testing.F) { + f.Add("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature") + f.Add("a.b.c") + f.Add("...") + f.Add("") + f.Add("no-dots-at-all") + + f.Fuzz(func(t *testing.T, token string) { + c := Config{Token: token} + _, _ = c.DecodeToken() + }) +} From 597f5de8ab22a2ef3096a9269e6a00fd9166a291 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 18:06:43 +0100 Subject: [PATCH 22/32] Add unit tests for DecodeToken, httpClient, and profile decoding --- ims/config_test.go | 158 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/ims/config_test.go b/ims/config_test.go index df7ae90..5cbcab0 100644 --- a/ims/config_test.go +++ b/ims/config_test.go @@ -11,7 +11,9 @@ package ims import ( + "encoding/base64" "math/rand" + "net/http" "testing" "time" ) @@ -396,6 +398,162 @@ func searchString(s, substr string) bool { return false } +func TestDecodeToken(t *testing.T) { + // Build a valid JWT with known header and payload. + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1234567890"}`)) + validJWT := header + "." + payload + ".signature" + + tests := []struct { + name string + token string + wantHeader string + wantPayload string + wantErr string + }{ + {name: "valid JWT", token: validJWT, wantHeader: `{"alg":"HS256"}`, wantPayload: `{"sub":"1234567890"}`}, + {name: "empty token", token: "", wantErr: "missing token parameter"}, + {name: "no dots", token: "nodots", wantErr: "not composed by 3 parts"}, + {name: "one dot", token: "a.b", wantErr: "not composed by 3 parts"}, + {name: "four parts", token: "a.b.c.d", wantErr: "not composed by 3 parts"}, + {name: "invalid base64 header", token: "!!!." + payload + ".sig", wantErr: "error decoding token header"}, + {name: "invalid base64 payload", token: header + ".!!!.sig", wantErr: "error decoding token payload"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Config{Token: tt.token} + result, err := c.DecodeToken() + assertError(t, err, tt.wantErr) + if err == nil { + if result.Header != tt.wantHeader { + t.Errorf("Header = %q, want %q", result.Header, tt.wantHeader) + } + if result.Payload != tt.wantPayload { + t.Errorf("Payload = %q, want %q", result.Payload, tt.wantPayload) + } + } + }) + } +} + +func TestHttpClient(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + wantProxy bool + wantInsecure bool + }{ + { + name: "default client", + config: Config{Timeout: 30}, + }, + { + name: "with proxy", + config: Config{Timeout: 30, ProxyURL: "http://proxy.example.com:8080"}, + wantProxy: true, + }, + { + name: "with proxy and ignore TLS", + config: Config{Timeout: 30, ProxyURL: "http://proxy.example.com:8080", ProxyIgnoreTLS: true}, + wantProxy: true, + wantInsecure: true, + }, + { + name: "malformed proxy URL", + config: Config{ProxyURL: "://bad"}, + wantErr: "malformed", + }, + { + name: "timeout is respected", + config: Config{Timeout: 60}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := tt.config.httpClient() + assertError(t, err, tt.wantErr) + if err != nil { + return + } + expectedTimeout := time.Duration(tt.config.Timeout) * time.Second + if client.Timeout != expectedTimeout { + t.Errorf("Timeout = %v, want %v", client.Timeout, expectedTimeout) + } + if tt.wantProxy { + transport, ok := client.Transport.(*http.Transport) + if !ok || transport.Proxy == nil { + t.Error("expected proxy to be configured") + } + if tt.wantInsecure { + if transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify to be true") + } + } + } + }) + } +} + +func TestNewIMSClient(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid config", config: Config{URL: "https://ims.example.com", Timeout: 30}}, + {name: "malformed proxy", config: Config{URL: "https://ims.example.com", ProxyURL: "://bad"}, wantErr: "malformed"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.config.newIMSClient() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestDecodeProfile(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + {name: "simple profile", input: `{"name":"John","email":"john@example.com"}`}, + {name: "profile with unrelated fulfillable_data", input: `{"serviceCode":"other","fulfillable_data":"test"}`}, + {name: "invalid JSON", input: `not json`, wantErr: "error parsing profile JSON"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := decodeProfile([]byte(tt.input)) + assertError(t, err, tt.wantErr) + }) + } +} + +func TestFindFulfillableData(t *testing.T) { + // Verify that findFulfillableData doesn't panic on various data structures. + tests := []struct { + name string + input interface{} + }{ + {name: "nil", input: nil}, + {name: "string", input: "hello"}, + {name: "number", input: 42.0}, + {name: "empty map", input: map[string]interface{}{}}, + {name: "empty slice", input: []interface{}{}}, + {name: "nested map", input: map[string]interface{}{"a": map[string]interface{}{"b": 1}}}, + {name: "fulfillable_data with wrong serviceCode", input: map[string]interface{}{"serviceCode": "unknown", "fulfillable_data": "test"}}, + {name: "fulfillable_data non-string value", input: map[string]interface{}{"serviceCode": "dma_media_library", "fulfillable_data": 123}}, + {name: "nested array", input: []interface{}{map[string]interface{}{"key": "value"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Should not panic. + findFulfillableData(tt.input) + }) + } +} + // randomString generates a string of the given length with arbitrary bytes. func randomString(rng *rand.Rand, length int) string { b := make([]byte, length) From 656aec7a0c1d10dc3895e07ef4192d5f29811e72 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 18:35:05 +0100 Subject: [PATCH 23/32] Add package-level doc comments to all packages Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- cmd/admin/profile.go | 1 + cmd/authz/user.go | 1 + cmd/invalidate/invalidate.go | 1 + cmd/pretty/json.go | 1 + cmd/root.go | 3 +++ cmd/validate/validate.go | 1 + 6 files changed, 8 insertions(+) diff --git a/cmd/admin/profile.go b/cmd/admin/profile.go index 9904ad8..9db3bcb 100644 --- a/cmd/admin/profile.go +++ b/cmd/admin/profile.go @@ -8,6 +8,7 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package admin implements the admin subcommands (profile, organizations). package admin import ( diff --git a/cmd/authz/user.go b/cmd/authz/user.go index 471982e..4daa39f 100644 --- a/cmd/authz/user.go +++ b/cmd/authz/user.go @@ -8,6 +8,7 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package authz implements the authorize subcommands (user, pkce, service, jwt, client). package authz import ( diff --git a/cmd/invalidate/invalidate.go b/cmd/invalidate/invalidate.go index 4a5f82a..32db000 100644 --- a/cmd/invalidate/invalidate.go +++ b/cmd/invalidate/invalidate.go @@ -8,6 +8,7 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package invalidate implements the invalidate subcommands for token invalidation. package invalidate import ( diff --git a/cmd/pretty/json.go b/cmd/pretty/json.go index 00fb1ee..c6ccaa0 100644 --- a/cmd/pretty/json.go +++ b/cmd/pretty/json.go @@ -8,6 +8,7 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package pretty provides output formatting helpers for the CLI. package pretty import ( diff --git a/cmd/root.go b/cmd/root.go index 483c24a..f160f29 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,9 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package cmd defines the Cobra command tree for the imscli CLI. +// Each subcommand is a function returning *cobra.Command that binds flags +// to the shared ims.Config struct. package cmd import ( diff --git a/cmd/validate/validate.go b/cmd/validate/validate.go index 10e7a18..72f7947 100644 --- a/cmd/validate/validate.go +++ b/cmd/validate/validate.go @@ -8,6 +8,7 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package validate implements the validate subcommands for token validation. package validate import ( From a7e4afaebc99604f96efc90cb13bee2261a97723 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 18:35:12 +0100 Subject: [PATCH 24/32] Add documentation for core types and exported functions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- ims/client.go | 4 ++-- ims/config.go | 9 ++++++++- ims/decode.go | 1 + 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ims/client.go b/ims/client.go index b99bacd..7cd1d97 100644 --- a/ims/client.go +++ b/ims/client.go @@ -18,13 +18,13 @@ import ( "time" ) -// Create a http client based on the received configuration. +// httpClient creates an HTTP client based on the received configuration. func (i Config) httpClient() (*http.Client, error) { if i.ProxyURL != "" { p, err := url.Parse(i.ProxyURL) if err != nil { - return nil, fmt.Errorf("proxy provided but its URL is malformed") + return nil, fmt.Errorf("proxy URL is malformed: %w", err) } t := http.DefaultTransport.(*http.Transport).Clone() t.Proxy = http.ProxyURL(p) diff --git a/ims/config.go b/ims/config.go index 2de3a75..2f804be 100644 --- a/ims/config.go +++ b/ims/config.go @@ -8,6 +8,10 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package ims implements the core business logic for interacting with the +// Adobe Identity Management System (IMS) API. It provides functions for +// authorization flows, token management, profile retrieval, and client +// registration. package ims import ( @@ -17,6 +21,8 @@ import ( "github.com/adobe/ims-go/ims" ) +// Config holds all parameters needed to interact with the IMS API. +// Fields are populated from CLI flags, environment variables, or a config file. type Config struct { URL string ClientID string @@ -50,13 +56,14 @@ type Config struct { RedirectURIs []string } -// Access token information +// TokenInfo holds the response data from token-related IMS API calls. type TokenInfo struct { AccessToken string Valid bool Info string } +// RefreshInfo extends TokenInfo with the new refresh token returned by a token refresh. type RefreshInfo struct { TokenInfo RefreshToken string diff --git a/ims/decode.go b/ims/decode.go index b961474..eb1b270 100644 --- a/ims/decode.go +++ b/ims/decode.go @@ -30,6 +30,7 @@ func (i Config) validateDecodeTokenConfig() error { return nil } +// DecodeToken decodes a JWT token into its header and payload parts. func (i Config) DecodeToken() (*DecodedToken, error) { err := i.validateDecodeTokenConfig() if err != nil { From 06d94ebb463cb83d8e8fb1fe85b3a694ba27e21c Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 18:35:19 +0100 Subject: [PATCH 25/32] Fix error wrapping and doc comments in token validate/invalidate Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- ims/invalidate.go | 8 +++++--- ims/validate.go | 14 +++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/ims/invalidate.go b/ims/invalidate.go index eda2112..509da9b 100644 --- a/ims/invalidate.go +++ b/ims/invalidate.go @@ -17,7 +17,7 @@ import ( "github.com/adobe/ims-go/ims" ) -// Invalidate a token. +// validateInvalidateTokenConfig checks that the configuration has valid parameters for token invalidation. func (i Config) validateInvalidateTokenConfig() error { switch { @@ -25,6 +25,8 @@ func (i Config) validateInvalidateTokenConfig() error { return fmt.Errorf("missing clientID parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.AccessToken != "": log.Println("access token will be invalidated") return nil @@ -45,7 +47,7 @@ func (i Config) validateInvalidateTokenConfig() error { } } -// InvalidateToken Invalidates the token provided in the configuration using the IMS API. +// InvalidateToken invalidates the token provided in the configuration using the IMS API. func (i Config) InvalidateToken() error { // Perform parameter validation err := i.validateInvalidateTokenConfig() @@ -60,7 +62,7 @@ func (i Config) InvalidateToken() error { token, tokenType, err := i.resolveToken() if err != nil { - return fmt.Errorf("unexpected error, broken parameter validation") + return fmt.Errorf("unexpected error resolving token: %w", err) } err = c.InvalidateToken(&ims.InvalidateTokenRequest{ diff --git a/ims/validate.go b/ims/validate.go index 3495900..ca47cbb 100644 --- a/ims/validate.go +++ b/ims/validate.go @@ -17,10 +17,8 @@ import ( "github.com/adobe/ims-go/ims" ) -// Validate that the config includes: -// - One clientID -// - One token to validate - +// validateValidateTokenConfig checks that the configuration has valid +// parameters for token validation. func (i Config) validateValidateTokenConfig() error { switch { @@ -28,6 +26,8 @@ func (i Config) validateValidateTokenConfig() error { return fmt.Errorf("missing clientID parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.AccessToken != "": log.Println("access token will be validated") return nil @@ -45,8 +45,8 @@ func (i Config) validateValidateTokenConfig() error { } } -// ValidateToken Validates the token provided in the configuration using the IMS API. -// Return the endpoint response or an error. +// ValidateToken validates the token provided in the configuration using the IMS API. +// It returns the endpoint response or an error. func (i Config) ValidateToken() (TokenInfo, error) { // Perform parameter validation err := i.validateValidateTokenConfig() @@ -61,7 +61,7 @@ func (i Config) ValidateToken() (TokenInfo, error) { token, tokenType, err := i.resolveToken() if err != nil { - return TokenInfo{}, fmt.Errorf("unexpected error, broken validation") + return TokenInfo{}, fmt.Errorf("unexpected error resolving token: %w", err) } r, err := c.ValidateToken(&ims.ValidateTokenRequest{ From 378bd6930635250e726aaedaf0629170c55c46c6 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 18:35:25 +0100 Subject: [PATCH 26/32] Fix error messages and doc comments in auth service flows Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- ims/authz_client.go | 6 ++++-- ims/authz_service.go | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ims/authz_client.go b/ims/authz_client.go index a4f26c9..4699798 100644 --- a/ims/authz_client.go +++ b/ims/authz_client.go @@ -20,6 +20,8 @@ func (i Config) validateAuthorizeClientCredentialsConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.ClientSecret == "": @@ -31,7 +33,7 @@ func (i Config) validateAuthorizeClientCredentialsConfig() error { } } -// AuthorizeClientCredentials : Client Credentials OAuth flow +// AuthorizeClientCredentials performs the Client Credentials OAuth flow. func (i Config) AuthorizeClientCredentials() (string, error) { if err := i.validateAuthorizeClientCredentialsConfig(); err != nil { @@ -50,7 +52,7 @@ func (i Config) AuthorizeClientCredentials() (string, error) { GrantType: "client_credentials", }) if err != nil { - return "", fmt.Errorf("request token: %w", err) + return "", fmt.Errorf("error requesting token: %w", err) } return r.AccessToken, nil diff --git a/ims/authz_service.go b/ims/authz_service.go index 3cc24db..f56df7a 100644 --- a/ims/authz_service.go +++ b/ims/authz_service.go @@ -20,6 +20,8 @@ func (i Config) validateAuthorizeServiceConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.ClientSecret == "": @@ -31,7 +33,7 @@ func (i Config) validateAuthorizeServiceConfig() error { } } -// AuthorizeService : Login for the service to service IMS flow +// AuthorizeService performs the service-to-service IMS authorization flow. func (i Config) AuthorizeService() (string, error) { if err := i.validateAuthorizeServiceConfig(); err != nil { @@ -49,7 +51,7 @@ func (i Config) AuthorizeService() (string, error) { Code: i.AuthorizationCode, }) if err != nil { - return "", fmt.Errorf("request token: %w", err) + return "", fmt.Errorf("error requesting token: %w", err) } return r.AccessToken, nil From f4429b4787156a30b6b0e31374cb15a95ba77187 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 18:35:32 +0100 Subject: [PATCH 27/32] Add validation function and constants for JWT exchange Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- ims/jwt_exchange.go | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/ims/jwt_exchange.go b/ims/jwt_exchange.go index 42a91f4..28ee38d 100644 --- a/ims/jwt_exchange.go +++ b/ims/jwt_exchange.go @@ -19,8 +19,37 @@ import ( "github.com/adobe/ims-go/ims" ) +// jwtExpiration is the lifetime of JWT assertions used for the exchange flow. +const jwtExpiration = 30 * time.Minute + +func (i Config) validateAuthorizeJWTExchangeConfig() error { + switch { + case i.URL == "": + return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") + case i.ClientID == "": + return fmt.Errorf("missing client ID parameter") + case i.ClientSecret == "": + return fmt.Errorf("missing client secret parameter") + case i.PrivateKeyPath == "": + return fmt.Errorf("missing private key path parameter") + case i.Organization == "": + return fmt.Errorf("missing organization parameter") + case i.Account == "": + return fmt.Errorf("missing account parameter") + default: + return nil + } +} + +// AuthorizeJWTExchange performs the JWT Bearer exchange flow. func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { + if err := i.validateAuthorizeJWTExchangeConfig(); err != nil { + return TokenInfo{}, fmt.Errorf("invalid parameters for JWT exchange: %w", err) + } + c, err := i.newIMSClient() if err != nil { return TokenInfo{}, fmt.Errorf("error creating the IMS client: %w", err) @@ -28,7 +57,7 @@ func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { key, err := os.ReadFile(i.PrivateKeyPath) if err != nil { - return TokenInfo{}, fmt.Errorf("error read private key file: %s, %w", i.PrivateKeyPath, err) + return TokenInfo{}, fmt.Errorf("error reading private key file %s: %w", i.PrivateKeyPath, err) } defer func() { for i := range key { @@ -48,7 +77,7 @@ func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { r, err := c.ExchangeJWT(&ims.ExchangeJWTRequest{ PrivateKey: key, - Expiration: time.Now().Add(time.Minute * 30), + Expiration: time.Now().Add(jwtExpiration), Issuer: i.Organization, Subject: i.Account, ClientID: i.ClientID, @@ -56,7 +85,7 @@ func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { Claims: claims, }) if err != nil { - return TokenInfo{}, fmt.Errorf("exchange JWT: %w", err) + return TokenInfo{}, fmt.Errorf("error exchanging JWT: %w", err) } return TokenInfo{ From cb68eabaf606cd06b4d0d4e30732405f3fe48656 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 18:35:38 +0100 Subject: [PATCH 28/32] Extract timeout constants for user authorization flow Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- ims/authz_user.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/ims/authz_user.go b/ims/authz_user.go index 452e946..e967b7f 100644 --- a/ims/authz_user.go +++ b/ims/authz_user.go @@ -24,11 +24,19 @@ import ( "github.com/pkg/browser" ) -// Validate that: -// - the ims.Config struct has the necessary parameters for AuthorizeUser -// - the provided environment exists -func (i Config) validateAuthorizeUserConfig() error { +const ( + // authTimeout is how long the CLI waits for the user to complete the browser-based + // OAuth flow before giving up. + authTimeout = 5 * time.Minute + + // shutdownTimeout is the grace period for the local HTTP server to finish + // serving in-flight requests during shutdown. + shutdownTimeout = 10 * time.Second +) +// validateAuthorizeUserConfig checks that the configuration has valid +// parameters for user authorization. +func (i Config) validateAuthorizeUserConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") @@ -143,7 +151,7 @@ func (i Config) authorizeUser(pkce bool) (string, error) { log.Println("The IMS HTTP handler returned a message.") case serr = <-serveCh: log.Println("The local server stopped unexpectedly.") - case <-time.After(time.Minute * 5): + case <-time.After(authTimeout): fmt.Fprintf(os.Stderr, "Timeout reached waiting for the user to finish the authentication ...\n") serr = fmt.Errorf("user timed out") } @@ -160,7 +168,7 @@ func (i Config) authorizeUser(pkce bool) (string, error) { } }() - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() if err = server.Shutdown(shutdownCtx); err != nil { From 27d98879a50b0375c26b30a0527543c905ce7427 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 18:35:45 +0100 Subject: [PATCH 29/32] Add URL validation to remaining IMS endpoint validators Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- ims/admin_organizations.go | 2 ++ ims/admin_profile.go | 2 ++ ims/exchange.go | 2 ++ ims/organizations.go | 2 ++ ims/profile.go | 16 ++++++++++++---- ims/refresh.go | 2 ++ 6 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ims/admin_organizations.go b/ims/admin_organizations.go index a239292..0fed4d6 100644 --- a/ims/admin_organizations.go +++ b/ims/admin_organizations.go @@ -30,6 +30,8 @@ func (i Config) validateGetAdminOrganizationsConfig() error { return fmt.Errorf("missing service token parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.Guid == "": diff --git a/ims/admin_profile.go b/ims/admin_profile.go index f69da7c..0de63a5 100644 --- a/ims/admin_profile.go +++ b/ims/admin_profile.go @@ -29,6 +29,8 @@ func (i Config) validateGetAdminProfileConfig() error { return fmt.Errorf("missing service token parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.Guid == "": diff --git a/ims/exchange.go b/ims/exchange.go index c1405d4..b686552 100644 --- a/ims/exchange.go +++ b/ims/exchange.go @@ -20,6 +20,8 @@ func (i Config) validateClusterExchangeConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.ClientSecret == "": diff --git a/ims/organizations.go b/ims/organizations.go index c9fadc4..0ec21cd 100644 --- a/ims/organizations.go +++ b/ims/organizations.go @@ -30,6 +30,8 @@ func (i Config) validateGetOrganizationsConfig() error { return fmt.Errorf("missing access token parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") default: log.Println("all needed parameters verified not empty") } diff --git a/ims/profile.go b/ims/profile.go index 28112f4..9549594 100644 --- a/ims/profile.go +++ b/ims/profile.go @@ -35,6 +35,8 @@ func (i Config) validateGetProfileConfig() error { return fmt.Errorf("missing access token parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") default: log.Println("all needed parameters verified not empty") } @@ -91,16 +93,22 @@ func decodeProfile(profile []byte) (string, error) { return string(modifiedJson), nil } +// fulfillableServiceCodes lists the service codes whose fulfillable_data field +// contains a base64+gzip-encoded payload that can be decoded. +var fulfillableServiceCodes = map[string]bool{ + "dma_media_library": true, + "dma_aem_cloud": true, + "dma_aem_contenthub": true, + "dx_genstudio": true, +} + func findFulfillableData(data interface{}) { switch data := data.(type) { case map[string]interface{}: for key, value := range data { if key == "fulfillable_data" { serviceCode, ok := data["serviceCode"].(string) - if ok && (serviceCode == "dma_media_library" || - serviceCode == "dma_aem_cloud" || - serviceCode == "dma_aem_contenthub" || - serviceCode == "dx_genstudio") { + if ok && fulfillableServiceCodes[serviceCode] { strValue, ok := value.(string) if !ok { diff --git a/ims/refresh.go b/ims/refresh.go index 82ce08e..389b38a 100644 --- a/ims/refresh.go +++ b/ims/refresh.go @@ -20,6 +20,8 @@ func (i Config) validateRefreshConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.ClientSecret == "": From 6ce64c34058ac3f93a098598217056d0790deecd Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 18:35:50 +0100 Subject: [PATCH 30/32] Harden DCR response handling with status check and size limit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- ims/register.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ims/register.go b/ims/register.go index d35c3d6..9be70a5 100644 --- a/ims/register.go +++ b/ims/register.go @@ -67,10 +67,14 @@ func (i Config) Register() (string, error) { } defer func() { _ = res.Body.Close() }() - body, err := io.ReadAll(res.Body) + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) // 1 MB max if err != nil { return "", fmt.Errorf("error reading response body: %w", err) } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return "", fmt.Errorf("registration failed with status %d: %s", res.StatusCode, string(body)) + } + return string(body), nil } From ddc79a19f89122558f562a67ed6289b5a38d60cb Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Sat, 28 Feb 2026 20:46:12 +0100 Subject: [PATCH 31/32] Add TODO with open items and test coverage learnings Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- TODO.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..4db9d72 --- /dev/null +++ b/TODO.md @@ -0,0 +1,55 @@ +# TODO + +## Slack Webhook Notifications + +Add Slack notifications on CI failures. This would post to a team channel when +the CI pipeline, govulncheck, or release workflows fail. + +## Comprehensive Test Coverage + +Current coverage is ~46%. The tested code covers pure functions (validation, +decoding, client construction, profile parsing) and fuzz tests. The untested +code is mostly thin wrappers that call `ims-go` through `newIMSClient()`. + +### What we tried + +**httptest approach**: Create a local HTTP server, point `Config.URL` at it, and +let the real code path execute end-to-end. This works mechanically — the tests +pass — but the assertions are shallow. The fake server returns canned responses +regardless of input, so we're testing that our code passes arguments through and +wraps results, not that it does anything meaningful. The tests are also tightly +coupled to `ims-go` implementation details (endpoint paths like +`/ims/validate_token/v1`), meaning they break if the library changes internals +even though our code is fine. + +### What the untested code actually looks like + +Each public function follows the same pattern: + +``` +validate config → create client → call ims-go → wrap result +``` + +The validation is already tested. The client creation is already tested. What +remains is ~5 lines of glue per function where httptest gives line coverage but +not meaningful assertions. + +### Open question + +Is there an approach that tests our behavior rather than `ims-go`'s wire format? +Options to explore: + +- Accept the shallow httptest coverage as "good enough" for glue code +- Focus testing effort on new features where logic is non-trivial +- Explore contract testing if `ims-go` publishes response schemas +- **Mock IMS server**: Build a standalone fake IMS service that understands the + real API contract (paths, params, auth headers) and returns realistic responses. + Run the CLI binary against it as an integration test. Tests real behavior + end-to-end including flag parsing, config loading, and output formatting — + not just the `ims/` package. Tradeoff: the mock needs maintaining as the API + evolves, but catches issues that unit tests never will. +- **Real IMS integration tests**: Run a subset of tests against the actual IMS + API using a dedicated test client. Gate behind a build tag + (`//go:build integration`) or env var so they don't run in CI by default. + Tradeoff: requires credentials, is slow, can flake on network issues — but + is the only way to catch real API drift or behavioral changes. From c4c6f263fcb0d15ea5f8ca5a67aeb254d95c1ae9 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Mon, 2 Mar 2026 11:15:44 +0100 Subject: [PATCH 32/32] Fix dangling res reference from merge conflict resolution --- ims/register.go | 1 - 1 file changed, 1 deletion(-) diff --git a/ims/register.go b/ims/register.go index d07b3c9..9be70a5 100644 --- a/ims/register.go +++ b/ims/register.go @@ -60,7 +60,6 @@ func (i Config) Register() (string, error) { if err != nil { return "", fmt.Errorf("error creating the HTTP client: %w", err) } - defer func() { _ = res.Body.Close() }() res, err := httpClient.Do(req) if err != nil {