Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
71c69fb
Allow test golden files and GitHub config in gitignore
telegrapher Feb 28, 2026
82ad9ee
Add missing golden test files for pretty-print tests
telegrapher Feb 28, 2026
9150ded
Fix stale references and add missing commands in documentation
telegrapher Feb 28, 2026
d04325f
Improve README with badges, commands table, and quick start
telegrapher Feb 28, 2026
b1cec9b
Add development setup, CI pipeline docs, and repo settings to contrib…
telegrapher Feb 28, 2026
576d4cc
Add CI and PR title validation workflows
telegrapher Feb 28, 2026
827460e
Clean up release workflow and add changelog grouping
telegrapher Feb 28, 2026
3faa3b8
Fix unchecked error returns flagged by linter
telegrapher Feb 28, 2026
37d0923
Clarify PKCE usage for public and private clients
telegrapher Feb 28, 2026
785da2c
Replace Codecov with coverage summary in CI log
telegrapher Feb 28, 2026
e77f0a8
Add govulncheck workflow for dependency vulnerability scanning
telegrapher Feb 28, 2026
0f01bba
Link docs/ write-ups from CONTRIBUTING.md
telegrapher Feb 28, 2026
b75534b
Enable Renovate auto-merge for patch dependency updates
telegrapher Feb 28, 2026
ead6e1c
Add weekly govulncheck schedule with auto-issue on failure
telegrapher Feb 28, 2026
53b7e57
Add go mod verify and goreleaser check to CI
telegrapher Feb 28, 2026
916f711
Use go-version stable across all workflows
telegrapher Feb 28, 2026
2603279
Revert to go-version-file go.mod for controllable Go version pinning
telegrapher Feb 28, 2026
a9bc578
Document Go version pinning strategy in CONTRIBUTING.md
telegrapher Feb 28, 2026
1ecaa4d
Fix govulncheck duplicate auth header by disabling persist-credentials
telegrapher Feb 28, 2026
27a09c9
Fix DCR consistency: safe JSON, proper HTTP client, error wrapping
telegrapher Feb 28, 2026
7013cdf
Add shell completion, verbose token expiration, and fuzz tests
telegrapher Feb 28, 2026
597f5de
Add unit tests for DecodeToken, httpClient, and profile decoding
telegrapher Feb 28, 2026
656aec7
Add package-level doc comments to all packages
telegrapher Feb 28, 2026
a7e4afa
Add documentation for core types and exported functions
telegrapher Feb 28, 2026
06d94eb
Fix error wrapping and doc comments in token validate/invalidate
telegrapher Feb 28, 2026
378bd69
Fix error messages and doc comments in auth service flows
telegrapher Feb 28, 2026
f4429b4
Add validation function and constants for JWT exchange
telegrapher Feb 28, 2026
cb68eab
Extract timeout constants for user authorization flow
telegrapher Feb 28, 2026
27d9887
Add URL validation to remaining IMS endpoint validators
telegrapher Feb 28, 2026
6ce64c3
Harden DCR response handling with status check and size limit
telegrapher Feb 28, 2026
ddc79a1
Add TODO with open items and test coverage learnings
telegrapher Feb 28, 2026
51b6c80
Merge branch 'main' into fix/dcr-consistency
telegrapher Mar 2, 2026
c4c6f26
Fix dangling res reference from merge conflict resolution
telegrapher Mar 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions cmd/admin/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions cmd/authz/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
55 changes: 55 additions & 0 deletions cmd/completion.go
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 13 additions & 3 deletions cmd/dcr.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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
},
}
Expand Down
35 changes: 34 additions & 1 deletion cmd/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"time"

"github.com/adobe/imscli/cmd/pretty"
"github.com/adobe/imscli/ims"
Expand All @@ -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)
Expand All @@ -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
},
}
Expand All @@ -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))
}
}
1 change: 1 addition & 0 deletions cmd/invalidate/invalidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions cmd/pretty/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
55 changes: 55 additions & 0 deletions cmd/pretty/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
package pretty

import (
"math/rand"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

func loadExpected(t *testing.T, filename string) string {
Expand Down Expand Up @@ -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)
})
}
5 changes: 5 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -34,6 +37,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)
},
}
Expand All @@ -58,6 +62,7 @@ func RootCmd(version string) *cobra.Command {
refreshCmd(imsConfig),
adminCmd(imsConfig),
dcrCmd(imsConfig),
completionCmd(),
)
return cmd
}
1 change: 1 addition & 0 deletions cmd/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 2 additions & 0 deletions ims/admin_organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "":
Expand Down
2 changes: 2 additions & 0 deletions ims/admin_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "":
Expand Down
6 changes: 4 additions & 2 deletions ims/authz_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "":
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading