Skip to content

Commit 0ced343

Browse files
committed
Add shell completion, verbose token expiration, and fuzz tests
1 parent ec7e36e commit 0ced343

File tree

6 files changed

+244
-1
lines changed

6 files changed

+244
-1
lines changed

cmd/completion.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2025 Adobe. All rights reserved.
2+
// This file is licensed to you under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License. You may obtain a copy
4+
// of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
//
6+
// Unless required by applicable law or agreed to in writing, software distributed under
7+
// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
8+
// OF ANY KIND, either express or implied. See the License for the specific language
9+
// governing permissions and limitations under the License.
10+
11+
package cmd
12+
13+
import (
14+
"os"
15+
16+
"github.com/spf13/cobra"
17+
)
18+
19+
func completionCmd() *cobra.Command {
20+
cmd := &cobra.Command{
21+
Use: "completion [bash|zsh|fish|powershell]",
22+
Short: "Generate shell completion script.",
23+
Long: `Generate a shell completion script for the specified shell.
24+
25+
# Bash
26+
source <(imscli completion bash)
27+
28+
# Zsh (add to ~/.zshrc or run once)
29+
imscli completion zsh > "${fpath[1]}/_imscli"
30+
31+
# Fish
32+
imscli completion fish | source
33+
34+
# PowerShell
35+
imscli completion powershell | Out-String | Invoke-Expression
36+
`,
37+
DisableFlagsInUseLine: true,
38+
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
39+
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
switch args[0] {
42+
case "bash":
43+
return cmd.Root().GenBashCompletionV2(os.Stdout, true)
44+
case "zsh":
45+
return cmd.Root().GenZshCompletion(os.Stdout)
46+
case "fish":
47+
return cmd.Root().GenFishCompletion(os.Stdout, true)
48+
case "powershell":
49+
return cmd.Root().GenPowerShellCompletion(os.Stdout)
50+
}
51+
return nil
52+
},
53+
}
54+
return cmd
55+
}

cmd/decode.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
package cmd
1212

1313
import (
14+
"encoding/json"
1415
"fmt"
16+
"os"
17+
"time"
1518

1619
"github.com/adobe/imscli/cmd/pretty"
1720
"github.com/adobe/imscli/ims"
@@ -27,7 +30,6 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command {
2730
RunE: func(cmd *cobra.Command, args []string) error {
2831
cmd.SilenceUsage = true
2932

30-
3133
decoded, err := imsConfig.DecodeToken()
3234
if err != nil {
3335
return fmt.Errorf("error decoding the token: %w", err)
@@ -36,6 +38,12 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command {
3638
output := fmt.Sprintf(`{"header":%s,"payload":%s}`, decoded.Header, decoded.Payload)
3739
fmt.Println(pretty.JSON(output))
3840

41+
// When verbose, show human-readable token expiration on stderr
42+
// so it doesn't pollute the JSON output on stdout.
43+
if imsConfig.Verbose {
44+
printTokenExpiration(decoded.Payload)
45+
}
46+
3947
return nil
4048
},
4149
}
@@ -44,3 +52,28 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command {
4452

4553
return cmd
4654
}
55+
56+
// printTokenExpiration parses the "exp" claim from a JWT payload and prints
57+
// a human-readable expiration message to stderr.
58+
func printTokenExpiration(payload string) {
59+
var claims map[string]interface{}
60+
if err := json.Unmarshal([]byte(payload), &claims); err != nil {
61+
return
62+
}
63+
64+
exp, ok := claims["exp"].(float64)
65+
if !ok {
66+
return
67+
}
68+
69+
expTime := time.Unix(int64(exp), 0).UTC()
70+
now := time.Now().UTC()
71+
72+
if now.After(expTime) {
73+
fmt.Fprintf(os.Stderr, "\nToken expired: %s (%s ago)\n",
74+
expTime.Format(time.RFC3339), now.Sub(expTime).Truncate(time.Second))
75+
} else {
76+
fmt.Fprintf(os.Stderr, "\nToken expires: %s (in %s)\n",
77+
expTime.Format(time.RFC3339), expTime.Sub(now).Truncate(time.Second))
78+
}
79+
}

cmd/pretty/json_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
package pretty
1212

1313
import (
14+
"math/rand"
1415
"os"
1516
"path/filepath"
1617
"strings"
1718
"testing"
19+
"time"
1820
)
1921

2022
func loadExpected(t *testing.T, filename string) string {
@@ -55,3 +57,56 @@ func TestJSON(t *testing.T) {
5557
})
5658
}
5759
}
60+
61+
// randomString generates a string of the given length with arbitrary bytes,
62+
// including invalid UTF-8, control characters, and JSON-significant characters.
63+
// Used by the fuzz tests below to verify that JSON never panics.
64+
func randomString(rng *rand.Rand, length int) string {
65+
b := make([]byte, length)
66+
for i := range b {
67+
b[i] = byte(rng.Intn(256))
68+
}
69+
return string(b)
70+
}
71+
72+
// TestFuzzJSON generates random inputs for 10 seconds to verify that JSON
73+
// never panics regardless of input. Runs in parallel with other tests.
74+
//
75+
// For deeper exploration, use Go's built-in fuzz engine:
76+
//
77+
// go test -fuzz=FuzzJSON -fuzztime=60s ./cmd/pretty/
78+
func TestFuzzJSON(t *testing.T) {
79+
t.Parallel()
80+
81+
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
82+
deadline := time.After(10 * time.Second)
83+
iterations := 0
84+
85+
for {
86+
select {
87+
case <-deadline:
88+
t.Logf("fuzz: %d iterations without panic", iterations)
89+
return
90+
default:
91+
input := randomString(rng, rng.Intn(1024))
92+
_ = JSON(input)
93+
iterations++
94+
}
95+
}
96+
}
97+
98+
// FuzzJSON is a standard Go fuzz target for deeper exploration.
99+
// Run manually: go test -fuzz=FuzzJSON -fuzztime=60s ./cmd/pretty/
100+
func FuzzJSON(f *testing.F) {
101+
f.Add(`{}`)
102+
f.Add(`[]`)
103+
f.Add(`{"a":1}`)
104+
f.Add(`"plain string"`)
105+
f.Add(`null`)
106+
f.Add(`not json`)
107+
f.Add(``)
108+
109+
f.Fuzz(func(t *testing.T, input string) {
110+
_ = JSON(input)
111+
})
112+
}

cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func RootCmd(version string) *cobra.Command {
3434
log.SetOutput(io.Discard)
3535
}
3636
// This call of the initParams will load all env vars, config file and flags.
37+
imsConfig.Verbose = verbose
3738
return initParams(cmd, imsConfig, configFile)
3839
},
3940
}
@@ -58,6 +59,7 @@ func RootCmd(version string) *cobra.Command {
5859
refreshCmd(imsConfig),
5960
adminCmd(imsConfig),
6061
dcrCmd(imsConfig),
62+
completionCmd(),
6163
)
6264
return cmd
6365
}

ims/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type Config struct {
4242
Token string
4343
Port int
4444
FullOutput bool
45+
Verbose bool
4546
Guid string
4647
AuthSrc string
4748
DecodeFulfillableData bool

ims/config_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
package ims
1212

1313
import (
14+
"math/rand"
1415
"testing"
16+
"time"
1517
)
1618

1719
func TestValidateDecodeTokenConfig(t *testing.T) {
@@ -393,3 +395,98 @@ func searchString(s, substr string) bool {
393395
}
394396
return false
395397
}
398+
399+
// randomString generates a string of the given length with arbitrary bytes.
400+
func randomString(rng *rand.Rand, length int) string {
401+
b := make([]byte, length)
402+
for i := range b {
403+
b[i] = byte(rng.Intn(256))
404+
}
405+
return string(b)
406+
}
407+
408+
// TestFuzzValidateURL generates random inputs for 10 seconds to verify that
409+
// validateURL never panics regardless of input. Runs in parallel with other tests.
410+
//
411+
// For deeper exploration, use Go's built-in fuzz engine:
412+
//
413+
// go test -fuzz=FuzzValidateURL -fuzztime=60s ./ims/
414+
func TestFuzzValidateURL(t *testing.T) {
415+
t.Parallel()
416+
417+
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
418+
deadline := time.After(10 * time.Second)
419+
iterations := 0
420+
421+
for {
422+
select {
423+
case <-deadline:
424+
t.Logf("fuzz: %d iterations without panic", iterations)
425+
return
426+
default:
427+
input := randomString(rng, rng.Intn(512))
428+
_ = validateURL(input)
429+
iterations++
430+
}
431+
}
432+
}
433+
434+
// FuzzValidateURL is a standard Go fuzz target for deeper exploration.
435+
// Run manually: go test -fuzz=FuzzValidateURL -fuzztime=60s ./ims/
436+
func FuzzValidateURL(f *testing.F) {
437+
f.Add("https://example.com")
438+
f.Add("http://localhost:8080")
439+
f.Add("")
440+
f.Add("not-a-url")
441+
f.Add("://missing-scheme.com")
442+
f.Add("https://")
443+
444+
f.Fuzz(func(t *testing.T, u string) {
445+
_ = validateURL(u)
446+
})
447+
}
448+
449+
// TestFuzzDecodeToken generates random inputs for 10 seconds to verify that
450+
// DecodeToken never panics regardless of input. Runs in parallel with other tests.
451+
//
452+
// For deeper exploration, use Go's built-in fuzz engine:
453+
//
454+
// go test -fuzz=FuzzDecodeToken -fuzztime=60s ./ims/
455+
func TestFuzzDecodeToken(t *testing.T) {
456+
t.Parallel()
457+
458+
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
459+
deadline := time.After(10 * time.Second)
460+
iterations := 0
461+
462+
for {
463+
select {
464+
case <-deadline:
465+
t.Logf("fuzz: %d iterations without panic", iterations)
466+
return
467+
default:
468+
// Generate random JWT-like strings (three dot-separated parts)
469+
input := randomString(rng, rng.Intn(128)) + "." +
470+
randomString(rng, rng.Intn(256)) + "." +
471+
randomString(rng, rng.Intn(128))
472+
c := Config{Token: input}
473+
_, _ = c.DecodeToken()
474+
iterations++
475+
}
476+
}
477+
}
478+
479+
// FuzzDecodeToken is a standard Go fuzz target for deeper exploration.
480+
// Run manually: go test -fuzz=FuzzDecodeToken -fuzztime=60s ./ims/
481+
func FuzzDecodeToken(f *testing.F) {
482+
f.Add("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature")
483+
f.Add("a.b.c")
484+
f.Add("...")
485+
f.Add("")
486+
f.Add("no-dots-at-all")
487+
488+
f.Fuzz(func(t *testing.T, token string) {
489+
c := Config{Token: token}
490+
_, _ = c.DecodeToken()
491+
})
492+
}

0 commit comments

Comments
 (0)