From 16d70123bf105e6bd19533cbbb4cc200ccb05699 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 26 Nov 2025 13:51:13 +0000 Subject: [PATCH] test: add unit tests for auth package - Test API token generation (uniqueness, format) - Test API token hashing (SHA3-256, deterministic) - Test constant-time token comparison - Test token hash detection - Test password hashing (bcrypt, salted) - Test password verification - Test password complexity validation - Verify bcrypt cost and minimum password length constants --- internal/auth/auth_test.go | 318 +++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 internal/auth/auth_test.go diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..ee1a361 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,318 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestGenerateAPIToken(t *testing.T) { + token, err := GenerateAPIToken() + if err != nil { + t.Fatalf("GenerateAPIToken() error: %v", err) + } + + // Should be 64 hex characters (32 bytes) + if len(token) != 64 { + t.Errorf("GenerateAPIToken() length = %d, want 64", len(token)) + } + + // Should be valid hex + for _, c := range token { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("GenerateAPIToken() contains invalid hex character: %c", c) + } + } + + // Should generate unique tokens + token2, err := GenerateAPIToken() + if err != nil { + t.Fatalf("GenerateAPIToken() second call error: %v", err) + } + if token == token2 { + t.Error("GenerateAPIToken() generated duplicate tokens") + } +} + +func TestHashAPIToken(t *testing.T) { + token := "test-token-12345" + hash := HashAPIToken(token) + + // Should be 64 hex characters (SHA3-256) + if len(hash) != 64 { + t.Errorf("HashAPIToken() length = %d, want 64", len(hash)) + } + + // Should be deterministic + hash2 := HashAPIToken(token) + if hash != hash2 { + t.Error("HashAPIToken() is not deterministic") + } + + // Different tokens should produce different hashes + hash3 := HashAPIToken("different-token") + if hash == hash3 { + t.Error("HashAPIToken() produced same hash for different tokens") + } +} + +func TestCompareAPIToken(t *testing.T) { + token := "my-secret-token-abc123" + hash := HashAPIToken(token) + + // Correct token should match + if !CompareAPIToken(token, hash) { + t.Error("CompareAPIToken() returned false for correct token") + } + + // Wrong token should not match + if CompareAPIToken("wrong-token", hash) { + t.Error("CompareAPIToken() returned true for wrong token") + } + + // Empty token should not match + if CompareAPIToken("", hash) { + t.Error("CompareAPIToken() returned true for empty token") + } + + // Token against wrong hash should not match + if CompareAPIToken(token, "0000000000000000000000000000000000000000000000000000000000000000") { + t.Error("CompareAPIToken() returned true for wrong hash") + } +} + +func TestCompareAPIToken_TimingSafe(t *testing.T) { + // This test verifies the comparison uses constant-time comparison + // by checking that it uses subtle.ConstantTimeCompare internally + // (verified by code inspection - this test just ensures the function works) + token := "timing-test-token" + hash := HashAPIToken(token) + + // Multiple comparisons should all succeed + for i := 0; i < 100; i++ { + if !CompareAPIToken(token, hash) { + t.Errorf("CompareAPIToken() failed on iteration %d", i) + } + } +} + +func TestIsAPITokenHashed(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "valid 64-char hex", + input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + expected: true, + }, + { + name: "actual hash", + input: HashAPIToken("test"), + expected: true, + }, + { + name: "too short", + input: "0123456789abcdef", + expected: false, + }, + { + name: "too long", + input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef00", + expected: false, + }, + { + name: "invalid hex characters", + input: "ghijklmnopqrstuv0123456789abcdef0123456789abcdef0123456789abcdef", + expected: false, + }, + { + name: "empty string", + input: "", + expected: false, + }, + { + name: "plain token (not hashed)", + input: "my-api-token", + expected: false, + }, + { + name: "uppercase hex", + input: "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", + expected: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := IsAPITokenHashed(tc.input) + if result != tc.expected { + t.Errorf("IsAPITokenHashed(%q) = %v, want %v", tc.input, result, tc.expected) + } + }) + } +} + +func TestHashPassword(t *testing.T) { + password := "mysecretpassword123" + + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("HashPassword() error: %v", err) + } + + // Should be a bcrypt hash (starts with $2) + if !strings.HasPrefix(hash, "$2") { + t.Errorf("HashPassword() did not produce bcrypt hash, got: %s", hash[:10]) + } + + // Should be 60 characters + if len(hash) != 60 { + t.Errorf("HashPassword() length = %d, want 60", len(hash)) + } + + // Same password should produce different hashes (due to salt) + hash2, err := HashPassword(password) + if err != nil { + t.Fatalf("HashPassword() second call error: %v", err) + } + if hash == hash2 { + t.Error("HashPassword() produced same hash twice (missing salt?)") + } +} + +func TestCheckPasswordHash(t *testing.T) { + password := "testpassword456" + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("HashPassword() error: %v", err) + } + + // Correct password should verify + if !CheckPasswordHash(password, hash) { + t.Error("CheckPasswordHash() returned false for correct password") + } + + // Wrong password should not verify + if CheckPasswordHash("wrongpassword", hash) { + t.Error("CheckPasswordHash() returned true for wrong password") + } + + // Empty password should not verify + if CheckPasswordHash("", hash) { + t.Error("CheckPasswordHash() returned true for empty password") + } +} + +func TestIsPasswordHashed(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "valid bcrypt hash", + input: "$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.qGAL0kcNWQKS5.", + expected: true, + }, + { + name: "bcrypt 2b variant", + input: "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.qGAL0kcNWQKS5.", + expected: true, + }, + { + name: "bcrypt 2y variant", + input: "$2y$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.qGAL0kcNWQKS5.", + expected: true, + }, + { + name: "plain password", + input: "mypassword", + expected: false, + }, + { + name: "empty string", + input: "", + expected: false, + }, + { + name: "wrong length", + input: "$2a$12$short", + expected: false, + }, + { + name: "wrong prefix", + input: "$1a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.qGAL0kcNWQKS5.", + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := IsPasswordHashed(tc.input) + if result != tc.expected { + t.Errorf("IsPasswordHashed(%q) = %v, want %v", tc.input, result, tc.expected) + } + }) + } +} + +func TestValidatePasswordComplexity(t *testing.T) { + tests := []struct { + name string + password string + wantError bool + }{ + { + name: "valid long password", + password: "thisisaverylongpassword", + wantError: false, + }, + { + name: "exactly minimum length", + password: "123456789012", // 12 chars + wantError: false, + }, + { + name: "too short", + password: "12345678901", // 11 chars + wantError: true, + }, + { + name: "empty password", + password: "", + wantError: true, + }, + { + name: "single character", + password: "a", + wantError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidatePasswordComplexity(tc.password) + if tc.wantError && err == nil { + t.Errorf("ValidatePasswordComplexity(%q) expected error, got nil", tc.password) + } + if !tc.wantError && err != nil { + t.Errorf("ValidatePasswordComplexity(%q) unexpected error: %v", tc.password, err) + } + }) + } +} + +func TestBcryptCost(t *testing.T) { + // Verify bcrypt cost is reasonable (10-14 is typical) + if BcryptCost < 10 || BcryptCost > 14 { + t.Errorf("BcryptCost = %d, should be between 10 and 14 for reasonable security/performance", BcryptCost) + } +} + +func TestMinPasswordLength(t *testing.T) { + // Verify minimum password length is reasonable (at least 8, ideally 12+) + if MinPasswordLength < 8 { + t.Errorf("MinPasswordLength = %d, should be at least 8", MinPasswordLength) + } +}