Mining/pkg/mining/auth_test.go
snider 87b426480b fix: Implement 6 quick wins from 109-finding code review
CONC-HIGH-1: Add mutex to wsClient.miners map to prevent race condition
P2P-CRIT-2: Add MaxMessageSize config (1MB default) to prevent memory exhaustion
P2P-CRIT-3: Track pending connections during handshake to enforce connection limits
RESIL-HIGH-1: Add recover() to 4 background goroutines to prevent service crashes
TEST-CRIT-1: Create auth_test.go with 16 tests covering Basic/Digest auth
RESIL-HIGH-3: Implement circuit breaker for GitHub API with caching fallback

Also fixed: NonceExpiry validation in auth.go to prevent panic on zero interval

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 15:03:46 +00:00

604 lines
14 KiB
Go

package mining
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestDefaultAuthConfig(t *testing.T) {
cfg := DefaultAuthConfig()
if cfg.Enabled {
t.Error("expected Enabled to be false by default")
}
if cfg.Username != "" {
t.Error("expected Username to be empty by default")
}
if cfg.Password != "" {
t.Error("expected Password to be empty by default")
}
if cfg.Realm != "Mining API" {
t.Errorf("expected Realm to be 'Mining API', got %s", cfg.Realm)
}
if cfg.NonceExpiry != 5*time.Minute {
t.Errorf("expected NonceExpiry to be 5 minutes, got %v", cfg.NonceExpiry)
}
}
func TestAuthConfigFromEnv(t *testing.T) {
// Save original env
origAuth := os.Getenv("MINING_API_AUTH")
origUser := os.Getenv("MINING_API_USER")
origPass := os.Getenv("MINING_API_PASS")
origRealm := os.Getenv("MINING_API_REALM")
defer func() {
os.Setenv("MINING_API_AUTH", origAuth)
os.Setenv("MINING_API_USER", origUser)
os.Setenv("MINING_API_PASS", origPass)
os.Setenv("MINING_API_REALM", origRealm)
}()
t.Run("auth disabled by default", func(t *testing.T) {
os.Setenv("MINING_API_AUTH", "")
cfg := AuthConfigFromEnv()
if cfg.Enabled {
t.Error("expected Enabled to be false when env not set")
}
})
t.Run("auth enabled with valid credentials", func(t *testing.T) {
os.Setenv("MINING_API_AUTH", "true")
os.Setenv("MINING_API_USER", "testuser")
os.Setenv("MINING_API_PASS", "testpass")
cfg := AuthConfigFromEnv()
if !cfg.Enabled {
t.Error("expected Enabled to be true")
}
if cfg.Username != "testuser" {
t.Errorf("expected Username 'testuser', got %s", cfg.Username)
}
if cfg.Password != "testpass" {
t.Errorf("expected Password 'testpass', got %s", cfg.Password)
}
})
t.Run("auth disabled if credentials missing", func(t *testing.T) {
os.Setenv("MINING_API_AUTH", "true")
os.Setenv("MINING_API_USER", "")
os.Setenv("MINING_API_PASS", "")
cfg := AuthConfigFromEnv()
if cfg.Enabled {
t.Error("expected Enabled to be false when credentials missing")
}
})
t.Run("custom realm", func(t *testing.T) {
os.Setenv("MINING_API_AUTH", "")
os.Setenv("MINING_API_REALM", "Custom Realm")
cfg := AuthConfigFromEnv()
if cfg.Realm != "Custom Realm" {
t.Errorf("expected Realm 'Custom Realm', got %s", cfg.Realm)
}
})
}
func TestNewDigestAuth(t *testing.T) {
cfg := AuthConfig{
Enabled: true,
Username: "user",
Password: "pass",
Realm: "Test",
NonceExpiry: time.Second,
}
da := NewDigestAuth(cfg)
if da == nil {
t.Fatal("expected non-nil DigestAuth")
}
// Cleanup
da.Stop()
}
func TestDigestAuthStop(t *testing.T) {
cfg := DefaultAuthConfig()
da := NewDigestAuth(cfg)
// Should not panic when called multiple times
da.Stop()
da.Stop()
da.Stop()
}
func TestMiddlewareAuthDisabled(t *testing.T) {
cfg := AuthConfig{Enabled: false}
da := NewDigestAuth(cfg)
defer da.Stop()
router := gin.New()
router.Use(da.Middleware())
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
req := httptest.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
if w.Body.String() != "success" {
t.Errorf("expected body 'success', got %s", w.Body.String())
}
}
func TestMiddlewareNoAuth(t *testing.T) {
cfg := AuthConfig{
Enabled: true,
Username: "user",
Password: "pass",
Realm: "Test",
NonceExpiry: 5 * time.Minute,
}
da := NewDigestAuth(cfg)
defer da.Stop()
router := gin.New()
router.Use(da.Middleware())
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
req := httptest.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
wwwAuth := w.Header().Get("WWW-Authenticate")
if wwwAuth == "" {
t.Error("expected WWW-Authenticate header")
}
if !authTestContains(wwwAuth, "Digest") {
t.Error("expected Digest challenge in WWW-Authenticate")
}
if !authTestContains(wwwAuth, `realm="Test"`) {
t.Error("expected realm in WWW-Authenticate")
}
}
func TestMiddlewareBasicAuthValid(t *testing.T) {
cfg := AuthConfig{
Enabled: true,
Username: "user",
Password: "pass",
Realm: "Test",
NonceExpiry: 5 * time.Minute,
}
da := NewDigestAuth(cfg)
defer da.Stop()
router := gin.New()
router.Use(da.Middleware())
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
req := httptest.NewRequest("GET", "/test", nil)
req.SetBasicAuth("user", "pass")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestMiddlewareBasicAuthInvalid(t *testing.T) {
cfg := AuthConfig{
Enabled: true,
Username: "user",
Password: "pass",
Realm: "Test",
NonceExpiry: 5 * time.Minute,
}
da := NewDigestAuth(cfg)
defer da.Stop()
router := gin.New()
router.Use(da.Middleware())
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
testCases := []struct {
name string
user string
password string
}{
{"wrong user", "wronguser", "pass"},
{"wrong password", "user", "wrongpass"},
{"both wrong", "wronguser", "wrongpass"},
{"empty user", "", "pass"},
{"empty password", "user", ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.SetBasicAuth(tc.user, tc.password)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
})
}
}
func TestMiddlewareDigestAuthValid(t *testing.T) {
cfg := AuthConfig{
Enabled: true,
Username: "testuser",
Password: "testpass",
Realm: "Test Realm",
NonceExpiry: 5 * time.Minute,
}
da := NewDigestAuth(cfg)
defer da.Stop()
router := gin.New()
router.Use(da.Middleware())
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
// First request to get nonce
req := httptest.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 to get nonce, got %d", w.Code)
}
wwwAuth := w.Header().Get("WWW-Authenticate")
params := parseDigestParams(wwwAuth[7:]) // Skip "Digest "
nonce := params["nonce"]
if nonce == "" {
t.Fatal("nonce not found in challenge")
}
// Build digest auth response
uri := "/test"
nc := "00000001"
cnonce := "abc123"
qop := "auth"
ha1 := md5Hash(fmt.Sprintf("%s:%s:%s", cfg.Username, cfg.Realm, cfg.Password))
ha2 := md5Hash(fmt.Sprintf("GET:%s", uri))
response := md5Hash(fmt.Sprintf("%s:%s:%s:%s:%s:%s", ha1, nonce, nc, cnonce, qop, ha2))
authHeader := fmt.Sprintf(
`Digest username="%s", realm="%s", nonce="%s", uri="%s", qop=%s, nc=%s, cnonce="%s", response="%s"`,
cfg.Username, cfg.Realm, nonce, uri, qop, nc, cnonce, response,
)
// Second request with digest auth
req2 := httptest.NewRequest("GET", "/test", nil)
req2.Header.Set("Authorization", authHeader)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Errorf("expected status 200, got %d; body: %s", w2.Code, w2.Body.String())
}
}
func TestMiddlewareDigestAuthInvalidNonce(t *testing.T) {
cfg := AuthConfig{
Enabled: true,
Username: "user",
Password: "pass",
Realm: "Test",
NonceExpiry: 5 * time.Minute,
}
da := NewDigestAuth(cfg)
defer da.Stop()
router := gin.New()
router.Use(da.Middleware())
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
// Try with a fake nonce that was never issued
authHeader := `Digest username="user", realm="Test", nonce="fakenonce123", uri="/test", qop=auth, nc=00000001, cnonce="abc", response="xxx"`
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", authHeader)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401 for invalid nonce, got %d", w.Code)
}
}
func TestMiddlewareDigestAuthExpiredNonce(t *testing.T) {
cfg := AuthConfig{
Enabled: true,
Username: "user",
Password: "pass",
Realm: "Test",
NonceExpiry: 50 * time.Millisecond, // Very short for testing
}
da := NewDigestAuth(cfg)
defer da.Stop()
router := gin.New()
router.Use(da.Middleware())
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "success")
})
// Get a valid nonce
req := httptest.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
wwwAuth := w.Header().Get("WWW-Authenticate")
params := parseDigestParams(wwwAuth[7:])
nonce := params["nonce"]
// Wait for nonce to expire
time.Sleep(100 * time.Millisecond)
// Try to use expired nonce
uri := "/test"
ha1 := md5Hash(fmt.Sprintf("%s:%s:%s", cfg.Username, cfg.Realm, cfg.Password))
ha2 := md5Hash(fmt.Sprintf("GET:%s", uri))
response := md5Hash(fmt.Sprintf("%s:%s:%s", ha1, nonce, ha2))
authHeader := fmt.Sprintf(
`Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`,
cfg.Username, cfg.Realm, nonce, uri, response,
)
req2 := httptest.NewRequest("GET", "/test", nil)
req2.Header.Set("Authorization", authHeader)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusUnauthorized {
t.Errorf("expected status 401 for expired nonce, got %d", w2.Code)
}
}
func TestParseDigestParams(t *testing.T) {
testCases := []struct {
name string
input string
expected map[string]string
}{
{
name: "basic params",
input: `username="john", realm="test"`,
expected: map[string]string{
"username": "john",
"realm": "test",
},
},
{
name: "params with spaces",
input: ` username = "john" , realm = "test" `,
expected: map[string]string{
"username": "john",
"realm": "test",
},
},
{
name: "unquoted values",
input: `qop=auth, nc=00000001`,
expected: map[string]string{
"qop": "auth",
"nc": "00000001",
},
},
{
name: "full digest header",
input: `username="user", realm="Test", nonce="abc123", uri="/api", qop=auth, nc=00000001, cnonce="xyz", response="hash"`,
expected: map[string]string{
"username": "user",
"realm": "Test",
"nonce": "abc123",
"uri": "/api",
"qop": "auth",
"nc": "00000001",
"cnonce": "xyz",
"response": "hash",
},
},
{
name: "empty string",
input: "",
expected: map[string]string{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := parseDigestParams(tc.input)
for key, expectedVal := range tc.expected {
if result[key] != expectedVal {
t.Errorf("key %s: expected %s, got %s", key, expectedVal, result[key])
}
}
})
}
}
func TestMd5Hash(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{"hello", "5d41402abc4b2a76b9719d911017c592"},
{"", "d41d8cd98f00b204e9800998ecf8427e"},
{"user:realm:password", func() string {
h := md5.Sum([]byte("user:realm:password"))
return hex.EncodeToString(h[:])
}()},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
result := md5Hash(tc.input)
if result != tc.expected {
t.Errorf("expected %s, got %s", tc.expected, result)
}
})
}
}
func TestNonceGeneration(t *testing.T) {
cfg := DefaultAuthConfig()
da := NewDigestAuth(cfg)
defer da.Stop()
nonces := make(map[string]bool)
for i := 0; i < 100; i++ {
nonce := da.generateNonce()
if len(nonce) != 32 { // 16 bytes = 32 hex chars
t.Errorf("expected nonce length 32, got %d", len(nonce))
}
if nonces[nonce] {
t.Error("duplicate nonce generated")
}
nonces[nonce] = true
}
}
func TestOpaqueGeneration(t *testing.T) {
cfg := AuthConfig{Realm: "TestRealm"}
da := NewDigestAuth(cfg)
defer da.Stop()
opaque1 := da.generateOpaque()
opaque2 := da.generateOpaque()
// Same realm should produce same opaque
if opaque1 != opaque2 {
t.Error("opaque should be consistent for same realm")
}
// Should be MD5 of realm
expected := md5Hash("TestRealm")
if opaque1 != expected {
t.Errorf("expected opaque %s, got %s", expected, opaque1)
}
}
func TestNonceCleanup(t *testing.T) {
cfg := AuthConfig{
Enabled: true,
Username: "user",
Password: "pass",
Realm: "Test",
NonceExpiry: 50 * time.Millisecond,
}
da := NewDigestAuth(cfg)
defer da.Stop()
// Store a nonce
nonce := da.generateNonce()
da.nonces.Store(nonce, time.Now())
// Verify it exists
if _, ok := da.nonces.Load(nonce); !ok {
t.Error("nonce should exist immediately after storing")
}
// Wait for cleanup (2x expiry to be safe)
time.Sleep(150 * time.Millisecond)
// Verify it was cleaned up
if _, ok := da.nonces.Load(nonce); ok {
t.Error("expired nonce should have been cleaned up")
}
}
// Helper function
func authTestContains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// Benchmark tests
func BenchmarkMd5Hash(b *testing.B) {
input := "user:realm:password"
for i := 0; i < b.N; i++ {
md5Hash(input)
}
}
func BenchmarkNonceGeneration(b *testing.B) {
cfg := DefaultAuthConfig()
da := NewDigestAuth(cfg)
defer da.Stop()
for i := 0; i < b.N; i++ {
da.generateNonce()
}
}
func BenchmarkBasicAuthValidation(b *testing.B) {
cfg := AuthConfig{
Enabled: true,
Username: "user",
Password: "pass",
Realm: "Test",
NonceExpiry: 5 * time.Minute,
}
da := NewDigestAuth(cfg)
defer da.Stop()
router := gin.New()
router.Use(da.Middleware())
router.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("user:pass")))
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}