package authd import ( "context" "testing" "time" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) // These tests cover the pure-Go logic in authd.go that does NOT // require Postgres: bcrypt password hashing/verification, JWT // signing/verification, magic-link token generation/hashing, and // the SecureEqual helper. The DB-backed paths are tested in // store_test.go (build tag 'postgres'). func TestHashPassword_And_Verify(t *testing.T) { cfg := DefaultConfig() cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min") a, err := New(nil, cfg) // nil pool ok for password tests if err != nil { t.Fatalf("new authd: %v", err) } hash, err := a.HashPassword(context.Background(), "hunter2-correct-horse") if err != nil { t.Fatalf("hash: %v", err) } if hash == "" { t.Fatal("hash is empty") } if err := a.VerifyPassword(hash, "hunter2-correct-horse"); err != nil { t.Errorf("verify correct: %v", err) } if err := a.VerifyPassword(hash, "wrong"); err == nil { t.Error("verify wrong: expected error, got nil") } } func TestHashPassword_BcryptCost(t *testing.T) { cfg := DefaultConfig() cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min") cfg.BcryptCost = bcrypt.MinCost // speed up test a, _ := New(nil, cfg) start := time.Now() _, err := a.HashPassword(context.Background(), "x") if err != nil { t.Fatal(err) } if elapsed := time.Since(start); elapsed > 2*time.Second { t.Errorf("bcrypt MinCost should be <2s, got %s", elapsed) } } func TestNew_RejectsShortSecret(t *testing.T) { cfg := DefaultConfig() cfg.JWTSecret = []byte("too-short") if _, err := New(nil, cfg); err == nil { t.Error("expected error for short JWT secret, got nil") } } func TestNew_AppliesDefaults(t *testing.T) { cfg := Config{ JWTSecret: []byte("this-is-a-test-secret-with-32-bytes-min"), } a, err := New(nil, cfg) if err != nil { t.Fatalf("new: %v", err) } if a.cfg.AccessTokenTTL != 15*time.Minute { t.Errorf("AccessTokenTTL default = %s, want 15m", a.cfg.AccessTokenTTL) } if a.cfg.RefreshTokenTTL != 7*24*time.Hour { t.Errorf("RefreshTokenTTL default = %s, want 7d", a.cfg.RefreshTokenTTL) } if a.cfg.MagicLinkTTL != 24*time.Hour { t.Errorf("MagicLinkTTL default = %s, want 24h", a.cfg.MagicLinkTTL) } if a.cfg.BcryptCost != 12 { t.Errorf("BcryptCost default = %d, want 12", a.cfg.BcryptCost) } } func TestIssueMagicLink_ReturnsHexToken(t *testing.T) { cfg := DefaultConfig() cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min") a, _ := New(nil, cfg) // IssueMagicLink requires a real DB (InsertMagicLink). We can // only test the token shape via the random source by // calling the parts we can. Skip if pool is nil. // To avoid coupling, we just check that the method exists and // the error path is what we expect (DB unavailable). _, _, err := a.IssueMagicLink(context.Background(), "00000000-0000-0000-0000-000000000000", "invite") if err == nil { t.Error("expected DB error, got nil (pool was nil)") } } func TestConsumeMagicLink_BadInput(t *testing.T) { cfg := DefaultConfig() cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min") a, _ := New(nil, cfg) // Wrong length _, err := a.ConsumeMagicLink(context.Background(), "tooshort", "1.1.1.1", "ua") if err != ErrMagicLinkInvalid { t.Errorf("short token: err = %v, want ErrMagicLinkInvalid", err) } // Bad hex _, err = a.ConsumeMagicLink(context.Background(), "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "1.1.1.1", "ua") if err != ErrMagicLinkInvalid { t.Errorf("bad hex: err = %v, want ErrMagicLinkInvalid", err) } } func TestVerifyAccessToken_RoundTrip(t *testing.T) { cfg := DefaultConfig() cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min") a, _ := New(nil, cfg) user := &User{ID: "u-1", TenantID: "t-1", Role: "tenant_admin", Status: "active"} accessJWT, jti, exp, err := a.mintAccessToken(user) if err != nil { t.Fatalf("mint: %v", err) } if accessJWT == "" { t.Fatal("empty token") } if jti == "" { t.Fatal("empty jti") } if !exp.After(time.Now()) { t.Fatal("token already expired") } claims, err := a.VerifyAccessToken(accessJWT) if err != nil { t.Fatalf("verify: %v", err) } if claims.UserID != "u-1" { t.Errorf("UserID = %q, want u-1", claims.UserID) } if claims.TenantID != "t-1" { t.Errorf("TenantID = %q, want t-1", claims.TenantID) } if claims.Role != "tenant_admin" { t.Errorf("Role = %q, want tenant_admin", claims.Role) } if claims.TokenType != "access" { t.Errorf("TokenType = %q, want access", claims.TokenType) } } func TestVerifyAccessToken_RejectsBadSecret(t *testing.T) { cfg := DefaultConfig() cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min") a, _ := New(nil, cfg) user := &User{ID: "u-1", Status: "active"} accessJWT, _, _, _ := a.mintAccessToken(user) // Same secret, different bytes other := DefaultConfig() other.JWTSecret = []byte("different-secret-with-32-bytes-min!!") b, _ := New(nil, other) if _, err := b.VerifyAccessToken(accessJWT); err == nil { t.Error("expected error from different-secret verifier, got nil") } } func TestVerifyAccessToken_RejectsExpired(t *testing.T) { cfg := DefaultConfig() cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min") cfg.AccessTokenTTL = -1 * time.Minute // already expired a, _ := New(nil, cfg) user := &User{ID: "u-1", Status: "active"} accessJWT, _, _, _ := a.mintAccessToken(user) _, err := a.VerifyAccessToken(accessJWT) if err == nil { t.Error("expected expired-token error, got nil") } } func TestVerifyAccessToken_RejectsAlgConfusion(t *testing.T) { // Classic alg=none / RS256-confused-as-HS256 attack. The verifier // must reject any non-HMAC alg. We craft a token signed with the // "none" alg and confirm it's rejected. cfg := DefaultConfig() cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min") a, _ := New(nil, cfg) claims := AccessClaims{ UserID: "u-1", Role: "tenant_admin", TokenType: "access", RegisteredClaims: jwt.RegisteredClaims{ Issuer: "broad-announce", Subject: "u-1", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, } t1 := jwt.NewWithClaims(jwt.SigningMethodNone, claims) noneToken, err := t1.SignedString(jwt.UnsafeAllowNoneSignatureType) if err != nil { t.Fatalf("sign with none: %v", err) } if _, err := a.VerifyAccessToken(noneToken); err == nil { t.Error("expected verifier to reject 'none' alg, got nil error") } } func TestSecureEqual(t *testing.T) { if !SecureEqual([]byte("abc"), []byte("abc")) { t.Error("equal slices: want true") } if SecureEqual([]byte("abc"), []byte("abd")) { t.Error("differing slices: want false") } if SecureEqual([]byte("abc"), []byte("abcd")) { t.Error("different lengths: want false") } } func TestNewJTI_Unique(t *testing.T) { seen := make(map[string]struct{}, 1000) for i := 0; i < 1000; i++ { id := newJTI() if _, dup := seen[id]; dup { t.Fatalf("duplicate JTI on iter %d: %s", i, id) } seen[id] = struct{}{} } } func TestNewJTI_HexShape(t *testing.T) { id := newJTI() if len(id) != 32 { t.Errorf("JTI length = %d, want 32 hex chars (16 bytes)", len(id)) } for _, c := range id { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { t.Fatalf("non-hex char in JTI: %q", c) } } }