Skip to content

Commit 1c2e52d

Browse files
test: raise coverage to 83% (target: 95)
Adds 9 test files covering: - crypto/aes: ErrEncrypt/ErrDecrypt Error+Unwrap, bad-key-len rejection - crypto/jwt: SignJWT/VerifyJWT roundtrip + bad-secret + malformed + future-iat + wrong-alg guard; SignOnboardingJWT/VerifyOnboardingJWT roundtrip + jti + 7d expiry + future-iat - crypto/fingerprint: Fingerprint IPv4 /24 collision, IPv6 /48, FingerprintIP AS-prefix stripping, ParseIP error path - crypto/token: GenerateAPIKey prefix + uniqueness, ErrTokenGenerate - queueprovider/kafka: name, caps, default-host, ErrNotImplemented, revoke no-op on empty keyID + error on non-empty - queueprovider/rabbitmq: name, caps, ErrNotImplemented, revoke no-op - queueprovider/legacyopen: name, all-false caps, builder defaults + honoring explicit config, IssueTenantCredentials AuthMode=legacy_open + empty-token error, Revoke is no-op - resourcetype: ToProto/FromProto for all 3 known types + unknown roundtrip - plans: StorageLimitMB (all services + unknown), ConnectionsLimit, TeamMemberLimit defaults, ThroughputLimit, CustomDomainsAllowed/Max, Vault/DeployLimits, QueueCountLimit, Backup accessors, Promotions/PriceMonthly/DisplayName/IsDedicatedTier/BillingPeriod, CanonicalTier yearly-suffix stripping Coverage: 66.9% -> 83.3%. Remaining gap is queueprovider/nats which is mostly network-bound NATS server interaction (covered partially by the contract test), and storageprovider/{dospaces,r2,s3} which require live S3 credential signing paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d7d02f7 commit 1c2e52d

9 files changed

Lines changed: 768 additions & 0 deletions

File tree

crypto/aes_errors_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package crypto_test
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"testing"
7+
8+
"instant.dev/common/crypto"
9+
)
10+
11+
// Cover the Error/Unwrap methods of the typed AES errors. These are surfaced to
12+
// callers who use errors.Is/errors.As to distinguish failure modes.
13+
14+
func TestErrEncrypt_Wrapping(t *testing.T) {
15+
cause := errors.New("enc boom")
16+
e := &crypto.ErrEncrypt{Cause: cause}
17+
if !strings.Contains(e.Error(), "enc boom") {
18+
t.Errorf("Error() = %q", e.Error())
19+
}
20+
if !errors.Is(e, cause) {
21+
t.Error("Unwrap should return cause")
22+
}
23+
}
24+
25+
func TestErrDecrypt_Wrapping(t *testing.T) {
26+
cause := errors.New("dec boom")
27+
e := &crypto.ErrDecrypt{Cause: cause}
28+
if !strings.Contains(e.Error(), "dec boom") {
29+
t.Errorf("Error() = %q", e.Error())
30+
}
31+
if !errors.Is(e, cause) {
32+
t.Error("Unwrap should return cause")
33+
}
34+
}
35+
36+
func TestEncrypt_BadKeyLen(t *testing.T) {
37+
// 5-byte key — AES rejects.
38+
_, err := crypto.Encrypt([]byte{1, 2, 3, 4, 5}, "x")
39+
if err == nil {
40+
t.Error("expected error for invalid key length")
41+
}
42+
}
43+
44+
func TestDecrypt_BadKeyLen(t *testing.T) {
45+
_, err := crypto.Decrypt([]byte{1, 2, 3}, "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBka")
46+
if err == nil {
47+
t.Error("expected error for invalid key length")
48+
}
49+
}

crypto/fingerprint_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package crypto_test
2+
3+
import (
4+
"net"
5+
"testing"
6+
7+
"instant.dev/common/crypto"
8+
)
9+
10+
func TestFingerprint_IPv4(t *testing.T) {
11+
ip := net.ParseIP("198.51.100.42")
12+
fp := crypto.Fingerprint(ip, 12345)
13+
if len(fp) != 32 {
14+
t.Errorf("expected 32-char hex, got %d", len(fp))
15+
}
16+
17+
// Same /24 subnet → same fingerprint
18+
other := net.ParseIP("198.51.100.7")
19+
fp2 := crypto.Fingerprint(other, 12345)
20+
if fp != fp2 {
21+
t.Errorf("same /24 should yield identical fingerprint, got %q vs %q", fp, fp2)
22+
}
23+
24+
// Different ASN → different fingerprint
25+
fp3 := crypto.Fingerprint(ip, 99999)
26+
if fp == fp3 {
27+
t.Errorf("different ASN should differ, both %q", fp)
28+
}
29+
}
30+
31+
func TestFingerprint_IPv6(t *testing.T) {
32+
ip := net.ParseIP("2001:db8::1")
33+
fp := crypto.Fingerprint(ip, 100)
34+
if len(fp) != 32 {
35+
t.Errorf("expected 32-char hex, got %d", len(fp))
36+
}
37+
other := net.ParseIP("2001:db8::ff")
38+
fp2 := crypto.Fingerprint(other, 100)
39+
if fp != fp2 {
40+
t.Errorf("same /48 IPv6 should yield identical fingerprint")
41+
}
42+
}
43+
44+
func TestParseIP(t *testing.T) {
45+
if ip := crypto.ParseIP("10.0.0.1"); ip == nil {
46+
t.Error("ParseIP returned nil for valid IP")
47+
}
48+
if ip := crypto.ParseIP("not-an-ip"); ip != nil {
49+
t.Errorf("ParseIP returned non-nil for garbage: %v", ip)
50+
}
51+
}
52+
53+
func TestFingerprintIP(t *testing.T) {
54+
fp, err := crypto.FingerprintIP("198.51.100.42", "AS12345")
55+
if err != nil {
56+
t.Fatalf("FingerprintIP: %v", err)
57+
}
58+
if len(fp) != 32 {
59+
t.Errorf("fp length = %d", len(fp))
60+
}
61+
62+
// lowercase "as" prefix should also be stripped
63+
fp2, err := crypto.FingerprintIP("198.51.100.42", "as12345")
64+
if err != nil {
65+
t.Fatalf("FingerprintIP lowercase: %v", err)
66+
}
67+
if fp != fp2 {
68+
t.Errorf("AS vs as: should be equal, %q vs %q", fp, fp2)
69+
}
70+
71+
// Empty ASN works too
72+
fp3, err := crypto.FingerprintIP("198.51.100.42", "")
73+
if err != nil {
74+
t.Fatalf("FingerprintIP empty asn: %v", err)
75+
}
76+
// fp3 may differ from fp because ASN differs; just check it's well-formed.
77+
if len(fp3) != 32 {
78+
t.Errorf("fp3 length = %d", len(fp3))
79+
}
80+
81+
// Invalid IP -> error
82+
if _, err := crypto.FingerprintIP("garbage", ""); err == nil {
83+
t.Error("expected error for invalid IP")
84+
}
85+
86+
// Plain number ASN (no prefix)
87+
fp4, err := crypto.FingerprintIP("10.0.0.1", "12345")
88+
if err != nil {
89+
t.Fatalf("plain asn: %v", err)
90+
}
91+
if len(fp4) != 32 {
92+
t.Errorf("fp4 length = %d", len(fp4))
93+
}
94+
}

crypto/jwt_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package crypto_test
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/golang-jwt/jwt/v4"
10+
11+
"instant.dev/common/crypto"
12+
)
13+
14+
var jwtSecret = []byte("supersecret-test-key-32-byte-minimum-required-here-pad-zzzzzz")
15+
16+
func TestSignAndVerifyJWT_Roundtrip(t *testing.T) {
17+
claims := crypto.InstantClaims{
18+
Fingerprint: "fp1",
19+
Country: "US",
20+
CloudVendor: "aws",
21+
Tokens: []string{"tok1"},
22+
ResourceTypes: []string{"postgres"},
23+
SuggestedPlan: "hobby",
24+
}
25+
signed, err := crypto.SignJWT(jwtSecret, claims)
26+
if err != nil {
27+
t.Fatalf("SignJWT: %v", err)
28+
}
29+
if signed == "" {
30+
t.Fatal("expected signed token")
31+
}
32+
33+
parsed, err := crypto.VerifyJWT(jwtSecret, signed)
34+
if err != nil {
35+
t.Fatalf("VerifyJWT: %v", err)
36+
}
37+
if parsed.Fingerprint != "fp1" || parsed.Country != "US" {
38+
t.Errorf("parsed = %+v", parsed)
39+
}
40+
if parsed.ID == "" {
41+
t.Error("expected auto-generated jti")
42+
}
43+
}
44+
45+
func TestVerifyJWT_BadSecret(t *testing.T) {
46+
signed, _ := crypto.SignJWT(jwtSecret, crypto.InstantClaims{Fingerprint: "x"})
47+
_, err := crypto.VerifyJWT([]byte("wrong-secret"), signed)
48+
if err == nil {
49+
t.Fatal("expected error from wrong secret")
50+
}
51+
}
52+
53+
func TestVerifyJWT_MalformedToken(t *testing.T) {
54+
_, err := crypto.VerifyJWT(jwtSecret, "not-a-jwt")
55+
if err == nil {
56+
t.Fatal("expected error for malformed token")
57+
}
58+
}
59+
60+
func TestVerifyJWT_FutureIssuedAt(t *testing.T) {
61+
claims := crypto.InstantClaims{Fingerprint: "fp"}
62+
claims.IssuedAt = jwt.NewNumericDate(time.Now().UTC().Add(2 * time.Hour))
63+
signed, err := crypto.SignJWT(jwtSecret, claims)
64+
if err != nil {
65+
t.Fatalf("SignJWT: %v", err)
66+
}
67+
_, err = crypto.VerifyJWT(jwtSecret, signed)
68+
if err == nil {
69+
t.Fatal("expected error for future iat")
70+
}
71+
}
72+
73+
func TestSignOnboardingJWT_Roundtrip(t *testing.T) {
74+
claims := crypto.OnboardingClaims{
75+
Fingerprint: "fp",
76+
Tokens: []string{"a", "b"},
77+
SuggestedPlan: "pro",
78+
}
79+
signed, jti, err := crypto.SignOnboardingJWT(jwtSecret, claims)
80+
if err != nil {
81+
t.Fatalf("SignOnboardingJWT: %v", err)
82+
}
83+
if jti == "" || signed == "" {
84+
t.Error("expected non-empty jti + signed")
85+
}
86+
parsed, err := crypto.VerifyOnboardingJWT(jwtSecret, signed)
87+
if err != nil {
88+
t.Fatalf("VerifyOnboardingJWT: %v", err)
89+
}
90+
if parsed.ID != jti {
91+
t.Errorf("ID = %q, want %q", parsed.ID, jti)
92+
}
93+
if len(parsed.Tokens) != 2 {
94+
t.Errorf("Tokens = %v", parsed.Tokens)
95+
}
96+
// ExpiresAt should be ~7 days from now
97+
if parsed.ExpiresAt == nil || time.Until(parsed.ExpiresAt.Time) < 6*24*time.Hour {
98+
t.Errorf("expected ~7d expiry, got %v", parsed.ExpiresAt)
99+
}
100+
}
101+
102+
func TestVerifyOnboardingJWT_Bad(t *testing.T) {
103+
if _, err := crypto.VerifyOnboardingJWT(jwtSecret, "garbage"); err == nil {
104+
t.Fatal("expected error")
105+
}
106+
}
107+
108+
func TestVerifyOnboardingJWT_FutureIssuedAt(t *testing.T) {
109+
// Hand-craft an onboarding JWT with iat in the future.
110+
claims := crypto.OnboardingClaims{Fingerprint: "fp"}
111+
claims.RegisteredClaims = jwt.RegisteredClaims{
112+
IssuedAt: jwt.NewNumericDate(time.Now().UTC().Add(2 * time.Hour)),
113+
ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(72 * time.Hour)),
114+
ID: "jti-future",
115+
}
116+
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
117+
signed, err := tok.SignedString(jwtSecret)
118+
if err != nil {
119+
t.Fatalf("manual sign: %v", err)
120+
}
121+
if _, err := crypto.VerifyOnboardingJWT(jwtSecret, signed); err == nil {
122+
t.Fatal("expected error for future iat")
123+
}
124+
}
125+
126+
// TestErrJWTSign_Wrapping exercises the Error/Unwrap on the typed errors.
127+
func TestErrJWTSign_Wrapping(t *testing.T) {
128+
cause := errors.New("underlying boom")
129+
e := &crypto.ErrJWTSign{Cause: cause}
130+
if !strings.Contains(e.Error(), "boom") {
131+
t.Errorf("Error() = %q", e.Error())
132+
}
133+
if !errors.Is(e, cause) {
134+
t.Errorf("Unwrap should return cause")
135+
}
136+
}
137+
138+
func TestErrJWTVerify_Wrapping(t *testing.T) {
139+
cause := errors.New("verify boom")
140+
e := &crypto.ErrJWTVerify{Cause: cause}
141+
if !strings.Contains(e.Error(), "verify boom") {
142+
t.Errorf("Error() = %q", e.Error())
143+
}
144+
if !errors.Is(e, cause) {
145+
t.Errorf("Unwrap should return cause")
146+
}
147+
}
148+
149+
// TestVerifyJWT_WrongAlg verifies the alg-confusion guard rejects tokens signed
150+
// with an unexpected method.
151+
func TestVerifyJWT_WrongAlg(t *testing.T) {
152+
// Sign with the "none" alg by forcing an unsigned token. The library refuses
153+
// to sign with "none" by default, so build with an unsupported alg path:
154+
// craft a token claiming alg=ES256 but signed with HS256 — the parser will
155+
// reject it because keyfunc only returns the HMAC key.
156+
tok := jwt.New(jwt.SigningMethodHS256)
157+
tok.Method = jwt.SigningMethodRS256 // mismatch — verify must refuse
158+
// Sign with HMAC anyway (force the wrong signature path).
159+
// Easier: pre-craft a fixed header-payload-fake-sig string.
160+
bad := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcCI6ImEifQ.sig"
161+
_, err := crypto.VerifyJWT(jwtSecret, bad)
162+
if err == nil {
163+
t.Fatal("expected error for non-HMAC alg")
164+
}
165+
}

crypto/token_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package crypto_test
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"testing"
7+
8+
"instant.dev/common/crypto"
9+
)
10+
11+
func TestGenerateAPIKey_Prefix(t *testing.T) {
12+
k1, err := crypto.GenerateAPIKey()
13+
if err != nil {
14+
t.Fatalf("GenerateAPIKey: %v", err)
15+
}
16+
if !strings.HasPrefix(k1, "inst_live_") {
17+
t.Errorf("missing prefix: %q", k1)
18+
}
19+
// 32-byte body → base64url ~43 chars; full key length is 10 + ~43.
20+
if len(k1) < 30 {
21+
t.Errorf("key suspiciously short: %q", k1)
22+
}
23+
}
24+
25+
func TestGenerateAPIKey_Unique(t *testing.T) {
26+
a, _ := crypto.GenerateAPIKey()
27+
b, _ := crypto.GenerateAPIKey()
28+
if a == b {
29+
t.Errorf("two keys collided: %q == %q", a, b)
30+
}
31+
}
32+
33+
func TestErrTokenGenerate_Wrapping(t *testing.T) {
34+
cause := errors.New("rng broke")
35+
e := &crypto.ErrTokenGenerate{Cause: cause}
36+
if !strings.Contains(e.Error(), "rng broke") {
37+
t.Errorf("Error() = %q", e.Error())
38+
}
39+
if !errors.Is(e, cause) {
40+
t.Errorf("Unwrap should return cause")
41+
}
42+
}

0 commit comments

Comments
 (0)