- Fix YAML tags in auth config struct (json -> yaml) - Update CLI configs to use pre-hashed API keys - Remove double hashing in WebSocket client - Fix port mapping (9102 -> 9103) in CLI commands - Update permission keys to use jobs:read, jobs:create, etc. - Clean up all debug logging from CLI and server - All user roles now authenticate correctly: * Admin: Can queue jobs and see all jobs * Researcher: Can queue jobs and see own jobs * Analyst: Can see status (read-only access) Multi-user authentication is now fully functional.
333 lines
8 KiB
Go
333 lines
8 KiB
Go
package auth
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jfraeys/fetch_ml/internal/auth"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ConfigWithAuth holds configuration with authentication
|
|
type ConfigWithAuth struct {
|
|
Auth auth.Config `yaml:"auth"`
|
|
}
|
|
|
|
func TestUserManagerGenerateKey(t *testing.T) {
|
|
// Create temporary config file
|
|
tempDir := t.TempDir()
|
|
configFile := filepath.Join(tempDir, "test_config.yaml")
|
|
|
|
// Initial config with auth enabled
|
|
config := ConfigWithAuth{
|
|
Auth: auth.Config{
|
|
Enabled: true,
|
|
APIKeys: map[auth.Username]auth.APIKeyEntry{
|
|
"existing_user": {
|
|
Hash: auth.APIKeyHash(auth.HashAPIKey("existing-key")),
|
|
Admin: false,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := yaml.Marshal(config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal config: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(configFile, data, 0600); err != nil {
|
|
t.Fatalf("Failed to write config: %v", err)
|
|
}
|
|
|
|
// Test generate-key command
|
|
configData, err := os.ReadFile(filepath.Clean(configFile))
|
|
if err != nil {
|
|
t.Fatalf("Failed to read config: %v", err)
|
|
}
|
|
|
|
var cfg ConfigWithAuth
|
|
if err := yaml.Unmarshal(configData, &cfg); err != nil {
|
|
t.Fatalf("Failed to parse config: %v", err)
|
|
}
|
|
|
|
// Generate API key
|
|
apiKey := auth.GenerateAPIKey()
|
|
|
|
// Add to config
|
|
if cfg.Auth.APIKeys == nil {
|
|
cfg.Auth.APIKeys = make(map[auth.Username]auth.APIKeyEntry)
|
|
}
|
|
cfg.Auth.APIKeys[auth.Username("test_user")] = auth.APIKeyEntry{
|
|
Hash: auth.APIKeyHash(auth.HashAPIKey(apiKey)),
|
|
Admin: false,
|
|
}
|
|
|
|
// Save config
|
|
updatedData, err := yaml.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal updated config: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(configFile, updatedData, 0600); err != nil {
|
|
t.Fatalf("Failed to write updated config: %v", err)
|
|
}
|
|
|
|
// Verify user was added
|
|
savedData, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read saved config: %v", err)
|
|
}
|
|
|
|
var savedCfg ConfigWithAuth
|
|
if err := yaml.Unmarshal(savedData, &savedCfg); err != nil {
|
|
t.Fatalf("Failed to parse saved config: %v", err)
|
|
}
|
|
|
|
if _, exists := savedCfg.Auth.APIKeys["test_user"]; !exists {
|
|
t.Error("test_user was not added to config")
|
|
}
|
|
|
|
// Verify existing user still exists
|
|
if _, exists := savedCfg.Auth.APIKeys["existing_user"]; !exists {
|
|
t.Error("existing_user was removed from config")
|
|
}
|
|
}
|
|
|
|
func TestUserManagerListUsers(t *testing.T) {
|
|
// Create temporary config file
|
|
tempDir := t.TempDir()
|
|
configFile := filepath.Join(tempDir, "test_config.yaml")
|
|
|
|
// Initial config
|
|
config := ConfigWithAuth{
|
|
Auth: auth.Config{
|
|
Enabled: true,
|
|
APIKeys: map[auth.Username]auth.APIKeyEntry{
|
|
"admin": {
|
|
Hash: auth.APIKeyHash(auth.HashAPIKey("admin-key")),
|
|
Admin: true,
|
|
},
|
|
"regular": {
|
|
Hash: auth.APIKeyHash(auth.HashAPIKey("user-key")),
|
|
Admin: false,
|
|
},
|
|
"admin_user": {
|
|
Hash: auth.APIKeyHash(auth.HashAPIKey("adminuser-key")),
|
|
Admin: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := yaml.Marshal(config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal config: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(configFile, data, 0600); err != nil {
|
|
t.Fatalf("Failed to write config: %v", err)
|
|
}
|
|
|
|
// Load and verify config
|
|
configData, err := os.ReadFile(filepath.Clean(configFile))
|
|
if err != nil {
|
|
t.Fatalf("Failed to read config: %v", err)
|
|
}
|
|
|
|
var cfg ConfigWithAuth
|
|
if err := yaml.Unmarshal(configData, &cfg); err != nil {
|
|
t.Fatalf("Failed to parse config: %v", err)
|
|
}
|
|
|
|
// Test user listing
|
|
userCount := len(cfg.Auth.APIKeys)
|
|
expectedCount := 3
|
|
|
|
if userCount != expectedCount {
|
|
t.Errorf("Expected %d users, got %d", expectedCount, userCount)
|
|
}
|
|
|
|
// Verify admin detection
|
|
keyMap := map[auth.Username]string{
|
|
"admin": "admin-key",
|
|
"regular": "user-key",
|
|
"admin_user": "adminuser-key",
|
|
}
|
|
|
|
for username := range cfg.Auth.APIKeys {
|
|
testKey := keyMap[username]
|
|
|
|
user, err := cfg.Auth.ValidateAPIKey(testKey)
|
|
if err != nil {
|
|
t.Errorf("Failed to validate user %s: %v", username, err)
|
|
continue // Skip admin check if validation failed
|
|
}
|
|
|
|
expectedAdmin := username == "admin" || username == "admin_user"
|
|
if user.Admin != expectedAdmin {
|
|
t.Errorf("User %s: expected admin=%v, got admin=%v", username, expectedAdmin, user.Admin)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUserManagerHashKey(t *testing.T) {
|
|
key := "test-api-key-123"
|
|
expectedHash := auth.HashAPIKey(key)
|
|
|
|
if expectedHash == "" {
|
|
t.Error("Hash should not be empty")
|
|
}
|
|
|
|
if len(expectedHash) != 64 {
|
|
t.Errorf("Expected hash length 64, got %d", len(expectedHash))
|
|
}
|
|
|
|
// Test consistency
|
|
hash2 := auth.HashAPIKey(key)
|
|
if expectedHash != hash2 {
|
|
t.Error("Hash should be consistent")
|
|
}
|
|
}
|
|
|
|
func TestConfigPersistence(t *testing.T) {
|
|
// Create temporary config file
|
|
tempDir := t.TempDir()
|
|
configFile := filepath.Join(tempDir, "test_config.yaml")
|
|
|
|
// Create initial config
|
|
config := ConfigWithAuth{
|
|
Auth: auth.Config{
|
|
Enabled: true,
|
|
APIKeys: map[auth.Username]auth.APIKeyEntry{},
|
|
},
|
|
}
|
|
|
|
data, err := yaml.Marshal(config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal config: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(configFile, data, 0600); err != nil {
|
|
t.Fatalf("Failed to write config: %v", err)
|
|
}
|
|
|
|
// Simulate multiple operations
|
|
operations := []struct {
|
|
username string
|
|
apiKey string
|
|
}{
|
|
{"user1", "key1"},
|
|
{"user2", "key2"},
|
|
{"admin_user", "admin-key"},
|
|
}
|
|
|
|
for _, op := range operations {
|
|
// Load config
|
|
configData, err := os.ReadFile(filepath.Clean(configFile))
|
|
if err != nil {
|
|
t.Fatalf("Failed to read config: %v", err)
|
|
}
|
|
|
|
var cfg ConfigWithAuth
|
|
if err := yaml.Unmarshal(configData, &cfg); err != nil {
|
|
t.Fatalf("Failed to parse config: %v", err)
|
|
}
|
|
|
|
// Add user
|
|
if cfg.Auth.APIKeys == nil {
|
|
cfg.Auth.APIKeys = make(map[auth.Username]auth.APIKeyEntry)
|
|
}
|
|
cfg.Auth.APIKeys[auth.Username(op.username)] = auth.APIKeyEntry{
|
|
Hash: auth.APIKeyHash(auth.HashAPIKey(op.apiKey)),
|
|
Admin: strings.Contains(strings.ToLower(op.username), "admin"),
|
|
}
|
|
|
|
// Save config
|
|
updatedData, err := yaml.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal updated config: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(configFile, updatedData, 0600); err != nil {
|
|
t.Fatalf("Failed to write updated config: %v", err)
|
|
}
|
|
|
|
// Small delay to ensure file system consistency
|
|
time.Sleep(1 * time.Millisecond)
|
|
}
|
|
|
|
// Verify final state
|
|
finalData, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read final config: %v", err)
|
|
}
|
|
|
|
var finalCfg ConfigWithAuth
|
|
if err := yaml.Unmarshal(finalData, &finalCfg); err != nil {
|
|
t.Fatalf("Failed to parse final config: %v", err)
|
|
}
|
|
|
|
if len(finalCfg.Auth.APIKeys) != len(operations) {
|
|
t.Errorf("Expected %d users, got %d", len(operations), len(finalCfg.Auth.APIKeys))
|
|
}
|
|
|
|
for _, op := range operations {
|
|
if _, exists := finalCfg.Auth.APIKeys[auth.Username(op.username)]; !exists {
|
|
t.Errorf("User %s not found in final config", op.username)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuthDisabled(t *testing.T) {
|
|
t.Setenv("FETCH_ML_ALLOW_INSECURE_AUTH", "1")
|
|
defer t.Setenv("FETCH_ML_ALLOW_INSECURE_AUTH", "")
|
|
|
|
// Create temporary config file with auth disabled
|
|
tempDir := t.TempDir()
|
|
configFile := filepath.Join(tempDir, "test_config.yaml")
|
|
|
|
config := ConfigWithAuth{
|
|
Auth: auth.Config{
|
|
Enabled: false,
|
|
APIKeys: map[auth.Username]auth.APIKeyEntry{}, // Empty
|
|
},
|
|
}
|
|
|
|
data, err := yaml.Marshal(config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal config: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(configFile, data, 0600); err != nil {
|
|
t.Fatalf("Failed to write config: %v", err)
|
|
}
|
|
|
|
// Load config
|
|
configData, err := os.ReadFile(filepath.Clean(configFile))
|
|
if err != nil {
|
|
t.Fatalf("Failed to read config: %v", err)
|
|
}
|
|
|
|
var cfg ConfigWithAuth
|
|
if err := yaml.Unmarshal(configData, &cfg); err != nil {
|
|
t.Fatalf("Failed to parse config: %v", err)
|
|
}
|
|
|
|
// Test validation with auth disabled
|
|
user, err := cfg.Auth.ValidateAPIKey("any-key")
|
|
if err != nil {
|
|
t.Errorf("Unexpected error with auth disabled: %v", err)
|
|
}
|
|
|
|
if user.Name != "default" {
|
|
t.Errorf("Expected default user, got %s", user.Name)
|
|
}
|
|
|
|
if !user.Admin {
|
|
t.Error("Default user should be admin")
|
|
}
|
|
}
|