Skip to content

Commit ae0e8b2

Browse files
committed
test(cmd): add comprehensive unit tests for CLI commands
- Add test infrastructure with executeCommand helper and stdout capture - Add 68 test functions covering all 27 CLI commands - Test HMAC (HS256/384/512), RSA (RS256/384/512), and ECDSA (ES256/384/512) - Test error cases: missing flags, invalid tokens, wrong keys, deprecated flags - Update CI workflow to include cmd package in coverage reporting - Achieve 91.3% coverage for cmd package, 95.5% total project coverage Closes #37
1 parent a59717f commit ae0e8b2

11 files changed

Lines changed: 2404 additions & 1 deletion

.github/workflows/coverage.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ jobs:
2424
go mod download
2525
go generate ./...
2626
go test -coverpkg=./... -coverprofile=profile.cov ./...
27-
sed -i '/cmd/d' profile.cov # remove cmd package from coverage
2827
total=$(go tool cover -func profile.cov | grep '^total:' | awk '{print $3}' | sed "s/%//")
2928
rm profile.cov
3029
echo "COVERAGE_VALUE=${total}" >> $GITHUB_ENV

cmd/cmd_test_helpers.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"crypto/ecdsa"
6+
"crypto/elliptic"
7+
"crypto/rand"
8+
"crypto/rsa"
9+
"crypto/x509"
10+
"encoding/pem"
11+
"fmt"
12+
"os"
13+
"path/filepath"
14+
"testing"
15+
16+
"github.com/spf13/cobra"
17+
)
18+
19+
// executeCommand executes a Cobra command and captures stdout/stderr.
20+
// This helper allows testing command execution without running the full CLI.
21+
//
22+
// Note: Since the actual commands use fmt.Println (which writes to os.Stdout)
23+
// instead of cmd.OutOrStdout(), we need to temporarily redirect os.Stdout
24+
// to capture the output.
25+
func executeCommand(cmd *cobra.Command, args ...string) (output string, err error) {
26+
// Create a pipe to capture stdout
27+
oldStdout := os.Stdout
28+
r, w, _ := os.Pipe()
29+
os.Stdout = w
30+
31+
// Also set the command output (for errors and usage)
32+
buf := new(bytes.Buffer)
33+
cmd.SetOut(buf)
34+
cmd.SetErr(buf)
35+
cmd.SetArgs(args)
36+
37+
// Execute the command
38+
err = cmd.Execute()
39+
40+
// Restore stdout
41+
_ = w.Close()
42+
os.Stdout = oldStdout
43+
44+
// Read captured output
45+
var capturedOutput bytes.Buffer
46+
_, _ = capturedOutput.ReadFrom(r)
47+
48+
// Return captured output (from stdout) plus command output (from SetOut)
49+
if err != nil {
50+
return capturedOutput.String() + buf.String(), fmt.Errorf("command execution failed: %w", err)
51+
}
52+
return capturedOutput.String() + buf.String(), nil
53+
}
54+
55+
// registerEncodeFlags registers all encoding-related flags on a command.
56+
// This mimics the flag registration done in root.go for encode commands.
57+
func registerEncodeFlags(cmd *cobra.Command) {
58+
cmd.Flags().StringP("payload", "p", "", "JSON payload")
59+
cmd.Flags().StringP("secret", "s", "", "HMAC secret")
60+
cmd.Flags().String("private-key", "", "path to RSA/ECDSA private key file")
61+
cmd.Flags().Bool("allow-weak-secret", false, "allow weak secrets")
62+
// Deprecated flags for backward compatibility
63+
cmd.Flags().String("p", "", "")
64+
_ = cmd.Flags().MarkDeprecated("p", "use --payload or -p instead")
65+
cmd.Flags().String("s", "", "")
66+
_ = cmd.Flags().MarkDeprecated("s", "use --secret or -s instead")
67+
cmd.Flags().String("pk", "", "")
68+
_ = cmd.Flags().MarkDeprecated("pk", "use --private-key instead")
69+
}
70+
71+
// registerDecodeFlags registers all decoding-related flags on a command.
72+
// This mimics the flag registration done in root.go for decode commands.
73+
func registerDecodeFlags(cmd *cobra.Command) {
74+
cmd.Flags().StringP("token", "t", "", "JWT token to decode")
75+
cmd.Flags().StringP("secret", "s", "", "HMAC secret")
76+
cmd.Flags().String("private-key", "", "path to RSA/ECDSA private key file")
77+
cmd.Flags().String("public-key", "", "path to RSA/ECDSA public key file")
78+
cmd.Flags().Bool("allow-weak-secret", false, "allow weak secrets")
79+
// Deprecated flags for backward compatibility
80+
cmd.Flags().String("t", "", "")
81+
_ = cmd.Flags().MarkDeprecated("t", "use --token or -t instead")
82+
cmd.Flags().String("s", "", "")
83+
_ = cmd.Flags().MarkDeprecated("s", "use --secret or -s instead")
84+
cmd.Flags().String("pk", "", "")
85+
_ = cmd.Flags().MarkDeprecated("pk", "use --private-key instead")
86+
cmd.Flags().String("pubk", "", "")
87+
_ = cmd.Flags().MarkDeprecated("pubk", "use --public-key instead")
88+
}
89+
90+
// createTempFile creates a temporary file with given content.
91+
// The file is created in t.TempDir() and will be automatically cleaned up.
92+
func createTempFile(t *testing.T, content []byte) string {
93+
t.Helper()
94+
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.pem")
95+
if err != nil {
96+
t.Fatalf("Failed to create temp file: %v", err)
97+
}
98+
if _, err := tmpFile.Write(content); err != nil {
99+
t.Fatalf("Failed to write to temp file: %v", err)
100+
}
101+
if err := tmpFile.Close(); err != nil {
102+
t.Fatalf("Failed to close temp file: %v", err)
103+
}
104+
return tmpFile.Name()
105+
}
106+
107+
// generateRSAKeyPair generates a test RSA key pair and returns file paths.
108+
// Both private and public keys are written to temporary files in PEM format.
109+
func generateRSAKeyPair(t *testing.T) (privateKeyPath, publicKeyPath string) {
110+
t.Helper()
111+
privateKey, err := rsa.GenerateKey(rand.Reader, testRSAKeySize)
112+
if err != nil {
113+
t.Fatalf("Failed to generate RSA key: %v", err)
114+
}
115+
116+
privateKeyPEM := &pem.Block{
117+
Type: "RSA PRIVATE KEY",
118+
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
119+
}
120+
privateKeyBytes := pem.EncodeToMemory(privateKeyPEM)
121+
122+
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
123+
if err != nil {
124+
t.Fatalf("Failed to marshal public key: %v", err)
125+
}
126+
publicKeyPEM := &pem.Block{
127+
Type: "PUBLIC KEY",
128+
Bytes: publicKeyBytes,
129+
}
130+
publicKeyPEMBytes := pem.EncodeToMemory(publicKeyPEM)
131+
132+
privateKeyPath = createTempFile(t, privateKeyBytes)
133+
publicKeyPath = createTempFile(t, publicKeyPEMBytes)
134+
return privateKeyPath, publicKeyPath
135+
}
136+
137+
// generateECDSAKeyPair generates a test ECDSA key pair for the given curve.
138+
// Both private and public keys are written to temporary files in PEM format.
139+
func generateECDSAKeyPair(t *testing.T, curve elliptic.Curve) (privateKeyPath, publicKeyPath string) {
140+
t.Helper()
141+
privateKey, err := ecdsa.GenerateKey(curve, rand.Reader)
142+
if err != nil {
143+
t.Fatalf("Failed to generate ECDSA key: %v", err)
144+
}
145+
146+
privateKeyBytes, err := x509.MarshalECPrivateKey(privateKey)
147+
if err != nil {
148+
t.Fatalf("Failed to marshal EC private key: %v", err)
149+
}
150+
privateKeyPEM := &pem.Block{
151+
Type: "EC PRIVATE KEY",
152+
Bytes: privateKeyBytes,
153+
}
154+
privateKeyPEMBytes := pem.EncodeToMemory(privateKeyPEM)
155+
156+
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
157+
if err != nil {
158+
t.Fatalf("Failed to marshal public key: %v", err)
159+
}
160+
publicKeyPEM := &pem.Block{
161+
Type: "PUBLIC KEY",
162+
Bytes: publicKeyBytes,
163+
}
164+
publicKeyPEMBytes := pem.EncodeToMemory(publicKeyPEM)
165+
166+
privateKeyPath = createTempFile(t, privateKeyPEMBytes)
167+
publicKeyPath = createTempFile(t, publicKeyPEMBytes)
168+
return privateKeyPath, publicKeyPath
169+
}
170+
171+
// createInvalidPEMFile creates a file with invalid PEM content for testing error handling.
172+
func createInvalidPEMFile(t *testing.T) string {
173+
t.Helper()
174+
return createTempFile(t, []byte("invalid pem content"))
175+
}
176+
177+
// createWrongTypePEMFile creates a PEM file with wrong type for testing error handling.
178+
func createWrongTypePEMFile(t *testing.T, pemType string) string {
179+
t.Helper()
180+
block := &pem.Block{
181+
Type: pemType,
182+
Bytes: []byte("some data"),
183+
}
184+
return createTempFile(t, pem.EncodeToMemory(block))
185+
}
186+
187+
// createMalformedRSAKeyFile creates a PEM file with malformed RSA key data.
188+
func createMalformedRSAKeyFile(t *testing.T) string {
189+
t.Helper()
190+
block := &pem.Block{
191+
Type: "RSA PRIVATE KEY",
192+
Bytes: []byte("malformed rsa key data"),
193+
}
194+
return createTempFile(t, pem.EncodeToMemory(block))
195+
}
196+
197+
// createMalformedECKeyFile creates a PEM file with malformed EC key data.
198+
func createMalformedECKeyFile(t *testing.T) string {
199+
t.Helper()
200+
block := &pem.Block{
201+
Type: "EC PRIVATE KEY",
202+
Bytes: []byte("malformed ec key data"),
203+
}
204+
return createTempFile(t, pem.EncodeToMemory(block))
205+
}
206+
207+
// getNonExistentPath returns a path that doesn't exist for testing file not found errors.
208+
func getNonExistentPath(t *testing.T) string {
209+
t.Helper()
210+
return filepath.Join(t.TempDir(), "nonexistent.pem")
211+
}
212+
213+
// Test constants used across multiple test files.
214+
const (
215+
// testRSAKeySize is the RSA key size used for test key generation (2048 bits).
216+
testRSAKeySize = 2048
217+
218+
// validPayload is a valid JSON payload for testing JWT encoding/decoding.
219+
validPayload = `{"sub":"1234567890","name":"John Doe","iat":1516239022}`
220+
221+
// invalidJSON is an invalid JSON string for testing error handling.
222+
invalidJSON = `{invalid json`
223+
224+
// complexPayload is a complex nested JSON for testing edge cases.
225+
complexPayload = `{"user":{"id":123,"name":"Alice","roles":["admin","user"]},"meta":{"created":"2024-01-01","nested":{"deep":true}}}`
226+
227+
// hs256Secret is a valid 32-byte secret for HS256 algorithm (exactly 32 bytes).
228+
hs256Secret = "12345678901234567890123456789012"
229+
230+
// hs384Secret is a valid 48-byte secret for HS384 algorithm (exactly 48 bytes).
231+
hs384Secret = "123456789012345678901234567890123456789012345678"
232+
233+
// hs512Secret is a valid 64-byte secret for HS512 algorithm (exactly 64 bytes).
234+
hs512Secret = "1234567890123456789012345678901234567890123456789012345678901234"
235+
236+
// weakSecret is a secret that doesn't meet minimum length requirements.
237+
weakSecret = "short"
238+
)

0 commit comments

Comments
 (0)