fetch_ml/tests/unit/auth/api_key_test.go
Jeremie Fraeys 02811c0ffe
fix: resolve TODOs and standardize tests
- Fix duplicate check in security_test.go lint warning
- Mark SHA256 tests as Legacy for backward compatibility
- Convert TODO comments to documentation (task, handlers, privacy)
- Update user_manager_test to use GenerateAPIKey pattern
2026-02-19 15:34:59 -05:00

418 lines
9.7 KiB
Go

package auth
import (
"testing"
"github.com/jfraeys/fetch_ml/internal/auth"
)
func TestGenerateAPIKey(t *testing.T) {
t.Parallel() // Enable parallel execution
key1, hashed1, err := auth.GenerateAPIKey()
if err != nil {
t.Fatalf("Failed to generate API key: %v", err)
}
if len(key1) != 64 { // 32 bytes = 64 hex chars
t.Errorf("Expected key length 64, got %d", len(key1))
}
if hashed1.Algorithm != "argon2id" {
t.Errorf("Expected algorithm argon2id, got %s", hashed1.Algorithm)
}
// Test uniqueness
key2, _, err := auth.GenerateAPIKey()
if err != nil {
t.Fatalf("Failed to generate second API key: %v", err)
}
if key1 == key2 {
t.Error("Generated keys should be unique")
}
}
func TestUserHasPermission(t *testing.T) {
t.Parallel()
tests := []struct {
name string
user *auth.User
permission string
want bool
}{
{
name: "wildcard grants all",
user: &auth.User{Permissions: map[string]bool{"*": true}},
want: true,
},
{
name: "direct permission match",
user: &auth.User{Permissions: map[string]bool{"jobs:create": true}},
permission: "jobs:create",
want: true,
},
{
name: "hierarchical permission match",
user: &auth.User{Permissions: map[string]bool{"jobs": true}},
permission: "jobs:create",
want: true,
},
{
name: "missing permission",
user: &auth.User{Permissions: map[string]bool{"jobs:read": true}},
permission: "jobs:create",
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.user.HasPermission(tt.permission); got != tt.want {
t.Fatalf("HasPermission(%q) = %v, want %v", tt.permission, got, tt.want)
}
})
}
}
func TestUserHasRole(t *testing.T) {
t.Parallel()
user := &auth.User{
Roles: []string{"admin", "data_scientist"},
}
if !user.HasRole("admin") {
t.Fatal("expected admin role to be present")
}
if user.HasRole("operator") {
t.Fatal("did not expect operator role to be present")
}
}
// TestHashAPIKey_Legacy tests the legacy SHA256 hashing (kept for backward compatibility)
func TestHashAPIKey_Legacy(t *testing.T) {
t.Parallel() // Enable parallel execution
key := "test-key-123"
hash := auth.HashAPIKey(key)
if len(hash) != 64 { // SHA256 = 64 hex chars
t.Errorf("Expected hash length 64, got %d", len(hash))
}
// Test consistency
hash2 := auth.HashAPIKey(key)
if hash != hash2 {
t.Error("Hash should be consistent for same key")
}
// Test different keys produce different hashes
hash3 := auth.HashAPIKey("different-key")
if hash == hash3 {
t.Error("Different keys should produce different hashes")
}
}
// TestHashAPIKeyKnownValues_Legacy tests SHA256 with known hash values (backward compatibility)
func TestHashAPIKeyKnownValues_Legacy(t *testing.T) {
t.Parallel()
tests := []struct {
name string
key string
expected string
}{
{
name: "password hash",
key: "password",
expected: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
},
{
name: "test hash",
key: "test",
expected: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := auth.HashAPIKey(tt.key); got != tt.expected {
t.Fatalf("HashAPIKey(%q) = %s, want %s", tt.key, got, tt.expected)
}
})
}
}
// TestHashAPIKeyConsistency_Legacy tests SHA256 hash consistency (backward compatibility)
func TestHashAPIKeyConsistency_Legacy(t *testing.T) {
t.Parallel()
key := "consistency-key"
hash1 := auth.HashAPIKey(key)
hash2 := auth.HashAPIKey(key)
if hash1 != hash2 {
t.Fatalf("HashAPIKey() not deterministic: %s vs %s", hash1, hash2)
}
if len(hash1) != 64 {
t.Fatalf("HashAPIKey() length = %d, want 64", len(hash1))
}
}
// TestValidateAPIKey_Legacy tests API key validation using SHA256 hashes (backward compatibility)
func TestValidateAPIKey_Legacy(t *testing.T) {
t.Parallel() // Enable parallel execution
config := auth.Config{
Enabled: true,
APIKeys: map[auth.Username]auth.APIKeyEntry{
"admin": {
Hash: auth.APIKeyHash(auth.HashAPIKey("admin-key")),
Admin: true,
},
"data_scientist": {
Hash: auth.APIKeyHash(auth.HashAPIKey("ds-key")),
Admin: false,
},
},
}
tests := []struct {
name string
apiKey string
wantErr bool
wantUser string
wantAdmin bool
}{
{
name: "valid admin key",
apiKey: "admin-key",
wantErr: false,
wantUser: "admin",
wantAdmin: true,
},
{
name: "valid user key",
apiKey: "ds-key",
wantErr: false,
wantUser: "data_scientist",
wantAdmin: false,
},
{
name: "invalid key",
apiKey: "wrong-key",
wantErr: true,
},
{
name: "empty key",
apiKey: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user, err := config.ValidateAPIKey(tt.apiKey)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if user.Name != tt.wantUser {
t.Errorf("Expected user %s, got %s", tt.wantUser, user.Name)
}
if user.Admin != tt.wantAdmin {
t.Errorf("Expected admin %v, got %v", tt.wantAdmin, user.Admin)
}
})
}
}
func TestValidateAPIKeyAuthDisabled(t *testing.T) {
t.Setenv("FETCH_ML_ALLOW_INSECURE_AUTH", "1")
defer t.Setenv("FETCH_ML_ALLOW_INSECURE_AUTH", "")
config := auth.Config{
Enabled: false,
APIKeys: map[auth.Username]auth.APIKeyEntry{}, // Empty
}
user, err := config.ValidateAPIKey("any-key")
if err != nil {
t.Errorf("Unexpected error when auth disabled: %v", err)
}
if user == nil {
t.Fatal("Expected user, got nil")
}
if user.Name != "default" {
t.Errorf("Expected default user, got %s", user.Name)
}
if !user.Admin {
t.Error("Default user should be admin")
}
}
func TestArgon2idHashing(t *testing.T) {
t.Parallel()
// Generate a key and hash it with Argon2id
plaintext, hashed, err := auth.GenerateAPIKey()
if err != nil {
t.Fatalf("Failed to generate API key: %v", err)
}
// Verify algorithm is argon2id
if hashed.Algorithm != "argon2id" {
t.Errorf("Expected algorithm argon2id, got %s", hashed.Algorithm)
}
// Verify hash is hex-encoded and correct length (32 bytes = 64 hex chars)
if len(hashed.Hash) != 64 {
t.Errorf("Expected hash length 64, got %d", len(hashed.Hash))
}
// Verify salt is present and correct length (16 bytes = 32 hex chars)
if len(hashed.Salt) != 32 {
t.Errorf("Expected salt length 32, got %d", len(hashed.Salt))
}
// Build HashedKey for verification
stored := &auth.HashedKey{
Hash: hashed.Hash,
Salt: hashed.Salt,
Algorithm: hashed.Algorithm,
}
// Verify the key matches
match, err := auth.VerifyAPIKey(plaintext, stored)
if err != nil {
t.Fatalf("VerifyAPIKey failed: %v", err)
}
if !match {
t.Error("VerifyAPIKey should return true for matching key")
}
// Verify wrong key doesn't match
match, err = auth.VerifyAPIKey("wrong-key", stored)
if err != nil {
t.Fatalf("VerifyAPIKey failed: %v", err)
}
if match {
t.Error("VerifyAPIKey should return false for wrong key")
}
}
func TestArgon2idDifferentSalts(t *testing.T) {
t.Parallel()
// Generate two keys - they should have different salts
_, hashed1, _ := auth.GenerateAPIKey()
_, hashed2, _ := auth.GenerateAPIKey()
// Salts should be different
if hashed1.Salt == hashed2.Salt {
t.Error("Different API keys should have different salts")
}
// Hashes should be different even if same key (but they won't be same key)
if hashed1.Hash == hashed2.Hash {
t.Error("Different API keys should produce different hashes")
}
}
func TestVerifyAPIKeyWithDifferentAlgorithms(t *testing.T) {
t.Parallel()
// Test Argon2id verification
plaintext := "test-key-for-verification"
hashedKey, err := auth.HashAPIKeyArgon2id(plaintext)
if err != nil {
t.Fatalf("Failed to hash with Argon2id: %v", err)
}
// Should verify successfully
match, err := auth.VerifyAPIKey(plaintext, hashedKey)
if err != nil {
t.Fatalf("VerifyAPIKey failed: %v", err)
}
if !match {
t.Error("Argon2id verification should succeed for correct key")
}
// Test with wrong algorithm
sha256Stored := &auth.HashedKey{
Hash: auth.HashAPIKey(plaintext),
Algorithm: "sha256",
}
match, err = auth.VerifyAPIKey(plaintext, sha256Stored)
if err != nil {
t.Fatalf("VerifyAPIKey with SHA256 failed: %v", err)
}
if !match {
t.Error("SHA256 verification should succeed for correct key")
}
}
func TestGenerateNewAPIKey(t *testing.T) {
t.Parallel()
plaintext, entry, err := auth.GenerateNewAPIKey(
true, // admin
[]string{"admin", "operator"}, // roles
map[string]bool{"*": true}, // permissions
)
if err != nil {
t.Fatalf("GenerateNewAPIKey failed: %v", err)
}
// Verify plaintext is valid
if len(plaintext) != 64 {
t.Errorf("Expected plaintext length 64, got %d", len(plaintext))
}
// Verify entry has correct values
if !entry.Admin {
t.Error("Expected Admin to be true")
}
if len(entry.Roles) != 2 || entry.Roles[0] != "admin" {
t.Errorf("Expected roles [admin operator], got %v", entry.Roles)
}
if entry.Algorithm != "argon2id" {
t.Errorf("Expected algorithm argon2id, got %s", entry.Algorithm)
}
// Verify the key can be validated
config := auth.Config{
Enabled: true,
APIKeys: map[auth.Username]auth.APIKeyEntry{
"testuser": entry,
},
}
user, err := config.ValidateAPIKey(plaintext)
if err != nil {
t.Fatalf("Failed to validate generated key: %v", err)
}
if user.Name != "testuser" {
t.Errorf("Expected user name testuser, got %s", user.Name)
}
if !user.Admin {
t.Error("Expected user to be admin")
}
}