first
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package security_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
func TestHashAndVerifyPassword_RoundTrip(t *testing.T) {
|
||||
hash, err := security.HashPassword("correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
|
||||
ok, err := security.VerifyPassword(hash, "correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("VerifyPassword() = false, want true for the correct password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_WrongPassword(t *testing.T) {
|
||||
hash, err := security.HashPassword("correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
|
||||
ok, err := security.VerifyPassword(hash, "wrong-password")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("VerifyPassword() = true, want false for a wrong password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword_UniqueSaltPerCall(t *testing.T) {
|
||||
hash1, err := security.HashPassword("same-password")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
hash2, err := security.HashPassword("same-password")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("two hashes of the same password with random salts must differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_InvalidFormat(t *testing.T) {
|
||||
_, err := security.VerifyPassword("not-a-valid-hash", "whatever")
|
||||
if err == nil {
|
||||
t.Fatal("VerifyPassword() error = nil, want error for malformed hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_TamperedHash(t *testing.T) {
|
||||
hash, err := security.HashPassword("correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
|
||||
tampered := hash[:len(hash)-4] + "abcd"
|
||||
ok, _ := security.VerifyPassword(tampered, "correct-horse-battery-staple")
|
||||
if ok {
|
||||
t.Fatal("VerifyPassword() = true for a tampered hash, want false")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user