package security_test import ( "testing" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "backend/internal/platform/security" ) const testSecret = "test-secret-please-do-not-use-in-prod" func TestIssueAndParseAccessToken_RoundTrip(t *testing.T) { userID := uuid.New() token, err := security.IssueAccessToken(testSecret, time.Minute, userID, "admin", security.AudienceAdmin) if err != nil { t.Fatalf("IssueAccessToken() error = %v", err) } claims, err := security.ParseAccessToken(testSecret, token, security.AudienceAdmin) if err != nil { t.Fatalf("ParseAccessToken() error = %v", err) } if claims.Subject != userID.String() { t.Errorf("claims.Subject = %q, want %q", claims.Subject, userID.String()) } if claims.Role != "admin" { t.Errorf("claims.Role = %q, want %q", claims.Role, "admin") } if claims.ID == "" { t.Error("claims.ID (jti) is empty, want a non-empty token id") } } func TestIssueAccessToken_RejectsEmptyRole(t *testing.T) { _, err := security.IssueAccessToken(testSecret, time.Minute, uuid.New(), "", security.AudienceAdmin) if err == nil { t.Fatal("IssueAccessToken() error = nil, want error for empty role") } } func TestParseAccessToken_WrongAudienceRejected(t *testing.T) { userID := uuid.New() token, err := security.IssueAccessToken(testSecret, time.Minute, userID, "admin", security.AudienceAdmin) if err != nil { t.Fatalf("IssueAccessToken() error = %v", err) } // An admin-space token must never be accepted as a customer-space token, // even though both spaces share the same signing secret. if _, err := security.ParseAccessToken(testSecret, token, security.AudienceCustomer); err == nil { t.Fatal("ParseAccessToken() error = nil, want error when audience does not match expected space") } } func TestParseAccessToken_WrongSecretRejected(t *testing.T) { token, err := security.IssueAccessToken(testSecret, time.Minute, uuid.New(), "admin", security.AudienceAdmin) if err != nil { t.Fatalf("IssueAccessToken() error = %v", err) } if _, err := security.ParseAccessToken("a-different-secret", token, security.AudienceAdmin); err == nil { t.Fatal("ParseAccessToken() error = nil, want error for a token signed with a different secret") } } func TestParseAccessToken_ExpiredTokenRejected(t *testing.T) { token, err := security.IssueAccessToken(testSecret, -time.Minute, uuid.New(), "admin", security.AudienceAdmin) if err != nil { t.Fatalf("IssueAccessToken() error = %v", err) } if _, err := security.ParseAccessToken(testSecret, token, security.AudienceAdmin); err == nil { t.Fatal("ParseAccessToken() error = nil, want error for an already-expired token") } } func TestParseAccessToken_RejectsNoneAlgorithm(t *testing.T) { // Craft a token that declares "alg":"none" and carries otherwise valid // claims, to make sure the parser refuses it outright rather than // trusting an attacker-chosen algorithm. claims := security.Claims{ Role: "admin", RegisteredClaims: jwt.RegisteredClaims{ Subject: uuid.New().String(), Audience: jwt.ClaimStrings{string(security.AudienceAdmin)}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), ID: uuid.NewString(), }, } token := jwt.NewWithClaims(jwt.SigningMethodNone, claims) unsigned, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType) if err != nil { t.Fatalf("craft none-alg token: %v", err) } if _, err := security.ParseAccessToken(testSecret, unsigned, security.AudienceAdmin); err == nil { t.Fatal("ParseAccessToken() error = nil, want error for an alg=none token") } } func TestParseAccessToken_RejectsInvalidSubject(t *testing.T) { secretBytes := []byte(testSecret) claims := security.Claims{ Role: "admin", RegisteredClaims: jwt.RegisteredClaims{ Subject: "not-a-uuid", Audience: jwt.ClaimStrings{string(security.AudienceAdmin)}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), ID: uuid.NewString(), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString(secretBytes) if err != nil { t.Fatalf("sign token: %v", err) } if _, err := security.ParseAccessToken(testSecret, signed, security.AudienceAdmin); err == nil { t.Fatal("ParseAccessToken() error = nil, want error for a non-UUID sub claim") } } func TestParseAccessToken_RejectsMissingRole(t *testing.T) { secretBytes := []byte(testSecret) claims := security.Claims{ RegisteredClaims: jwt.RegisteredClaims{ Subject: uuid.New().String(), Audience: jwt.ClaimStrings{string(security.AudienceAdmin)}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), ID: uuid.NewString(), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString(secretBytes) if err != nil { t.Fatalf("sign token: %v", err) } if _, err := security.ParseAccessToken(testSecret, signed, security.AudienceAdmin); err == nil { t.Fatal("ParseAccessToken() error = nil, want error when role claim is missing") } }