Update comprehensive test coverage: - E2E tests with scheduler integration - Integration tests with tenant isolation - Unit tests with security assertions - Security tests with audit validation - Audit verification tests - Auth tests with tenant scoping - Config validation tests - Container security tests - Worker tests with scheduler mock - Environment pool tests - Load tests with distributed patterns - Test fixtures with scheduler support - Update go.mod/go.sum with new dependencies
418 lines
9.7 KiB
Go
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
|
|
wantUser string
|
|
wantErr bool
|
|
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")
|
|
}
|
|
}
|