Passwort und Reset Passwort tests
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bamort/database"
|
||||
"bamort/user"
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
BaseRouterGrp(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func setupTestUserForRouter(t *testing.T) user.User {
|
||||
database.SetupTestDB()
|
||||
|
||||
// Migrate User structure to ensure reset password fields exist
|
||||
err := user.MigrateStructure()
|
||||
require.NoError(t, err, "Failed to migrate user structure")
|
||||
|
||||
// Generate unique user for each test
|
||||
randomSuffix := rand.Intn(100000)
|
||||
testUser := user.User{
|
||||
Username: fmt.Sprintf("routetest_user_%d", randomSuffix),
|
||||
PasswordHash: "testpassword123",
|
||||
Email: fmt.Sprintf("routetest.%d@example.com", randomSuffix),
|
||||
}
|
||||
|
||||
// Hash password like in RegisterUser
|
||||
hashedPassword := md5.Sum([]byte(testUser.PasswordHash))
|
||||
testUser.PasswordHash = hex.EncodeToString(hashedPassword[:])
|
||||
|
||||
err = testUser.Create()
|
||||
require.NoError(t, err, "Failed to create test user")
|
||||
|
||||
return testUser
|
||||
}
|
||||
|
||||
func TestPasswordResetRoutes_Integration(t *testing.T) {
|
||||
testUser := setupTestUserForRouter(t)
|
||||
router := setupTestRouter()
|
||||
|
||||
t.Run("Complete Password Reset Flow", func(t *testing.T) {
|
||||
// Step 1: Request password reset
|
||||
requestData := map[string]interface{}{
|
||||
"email": testUser.Email,
|
||||
"redirect_url": "http://localhost:3000",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, response["message"], "Falls ein Account mit dieser E-Mail-Adresse existiert")
|
||||
|
||||
// Step 2: Get the reset token from database
|
||||
var dbUser user.User
|
||||
err = dbUser.FindByEmail(testUser.Email)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dbUser.ResetPwHash, "Reset hash should be set")
|
||||
|
||||
resetToken := *dbUser.ResetPwHash
|
||||
|
||||
// Step 3: Validate the reset token
|
||||
req, _ = http.NewRequest("GET", "/password-reset/validate/"+resetToken, nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, response["valid"].(bool))
|
||||
assert.Equal(t, testUser.Username, response["username"])
|
||||
|
||||
// Step 4: Reset the password
|
||||
resetData := map[string]interface{}{
|
||||
"token": resetToken,
|
||||
"new_password": "new_secure_password_123",
|
||||
}
|
||||
jsonData, _ = json.Marshal(resetData)
|
||||
|
||||
req, _ = http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Passwort erfolgreich zurückgesetzt", response["message"])
|
||||
|
||||
// Step 5: Verify password was changed and reset token was cleared
|
||||
var refreshedUser user.User
|
||||
err = refreshedUser.FindByEmail(testUser.Email)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedHash := md5.Sum([]byte("new_secure_password_123"))
|
||||
expectedHashString := hex.EncodeToString(expectedHash[:])
|
||||
assert.Equal(t, expectedHashString, refreshedUser.PasswordHash, "Password should have changed")
|
||||
assert.Nil(t, refreshedUser.ResetPwHash, "Reset hash should be cleared")
|
||||
assert.Nil(t, refreshedUser.ResetPwHashExpires, "Reset expiry should be cleared")
|
||||
|
||||
// Step 6: Verify old token is no longer valid
|
||||
req, _ = http.NewRequest("GET", "/password-reset/validate/"+resetToken, nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordResetRequestRoute(t *testing.T) {
|
||||
router := setupTestRouter()
|
||||
|
||||
t.Run("POST /password-reset/request - Success", func(t *testing.T) {
|
||||
testUser := setupTestUserForRouter(t)
|
||||
|
||||
requestData := map[string]interface{}{
|
||||
"email": testUser.Email,
|
||||
"redirect_url": "http://localhost:3000",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
|
||||
})
|
||||
|
||||
t.Run("POST /password-reset/request - Non-existent email", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
requestData := map[string]interface{}{
|
||||
"email": "nonexistent@example.com",
|
||||
"redirect_url": "http://localhost:3000",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return success to prevent email enumeration
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
|
||||
t.Run("POST /password-reset/request - Invalid JSON", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer([]byte("{invalid json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("POST /password-reset/request - Missing email", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
requestData := map[string]interface{}{
|
||||
"redirect_url": "http://localhost:3000",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("POST /password-reset/request - Invalid email format", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
requestData := map[string]interface{}{
|
||||
"email": "not-an-email",
|
||||
"redirect_url": "http://localhost:3000",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordResetValidateRoute(t *testing.T) {
|
||||
router := setupTestRouter()
|
||||
|
||||
t.Run("GET /password-reset/validate/:token - Success", func(t *testing.T) {
|
||||
testUser := setupTestUserForRouter(t)
|
||||
|
||||
// Set a reset hash for the user
|
||||
resetHash := "valid_test_token_123456789"
|
||||
err := testUser.SetPasswordResetHash(resetHash)
|
||||
require.NoError(t, err)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/password-reset/validate/"+resetHash, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, response["valid"].(bool))
|
||||
assert.Equal(t, testUser.Username, response["username"])
|
||||
assert.NotNil(t, response["expires"])
|
||||
})
|
||||
|
||||
t.Run("GET /password-reset/validate/:token - Invalid token", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
req, _ := http.NewRequest("GET", "/password-reset/validate/invalid_token_123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("GET /password-reset/validate/:token - Empty token", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
req, _ := http.NewRequest("GET", "/password-reset/validate/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return 404 because the route doesn't match
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordResetResetRoute(t *testing.T) {
|
||||
router := setupTestRouter()
|
||||
|
||||
t.Run("POST /password-reset/reset - Success", func(t *testing.T) {
|
||||
testUser := setupTestUserForRouter(t)
|
||||
originalPassword := testUser.PasswordHash
|
||||
|
||||
// Set a reset hash for the user
|
||||
resetHash := "valid_reset_token_for_password_change"
|
||||
err := testUser.SetPasswordResetHash(resetHash)
|
||||
require.NoError(t, err)
|
||||
|
||||
resetData := map[string]interface{}{
|
||||
"token": resetHash,
|
||||
"new_password": "new_secure_password_456",
|
||||
}
|
||||
jsonData, _ := json.Marshal(resetData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Passwort erfolgreich zurückgesetzt", response["message"])
|
||||
|
||||
// Verify password was changed
|
||||
var dbUser user.User
|
||||
err = dbUser.FindByEmail(testUser.Email)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, originalPassword, dbUser.PasswordHash, "Password should have changed")
|
||||
assert.Nil(t, dbUser.ResetPwHash, "Reset hash should be cleared")
|
||||
})
|
||||
|
||||
t.Run("POST /password-reset/reset - Invalid token", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
resetData := map[string]interface{}{
|
||||
"token": "invalid_token_123",
|
||||
"new_password": "new_secure_password_456",
|
||||
}
|
||||
jsonData, _ := json.Marshal(resetData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("POST /password-reset/reset - Short password", func(t *testing.T) {
|
||||
testUser := setupTestUserForRouter(t)
|
||||
|
||||
resetHash := "valid_token_short_password"
|
||||
err := testUser.SetPasswordResetHash(resetHash)
|
||||
require.NoError(t, err)
|
||||
|
||||
resetData := map[string]interface{}{
|
||||
"token": resetHash,
|
||||
"new_password": "123", // Too short
|
||||
}
|
||||
jsonData, _ := json.Marshal(resetData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("POST /password-reset/reset - Missing token", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
resetData := map[string]interface{}{
|
||||
"new_password": "new_secure_password_456",
|
||||
}
|
||||
jsonData, _ := json.Marshal(resetData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("POST /password-reset/reset - Missing password", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
resetData := map[string]interface{}{
|
||||
"token": "some_token_123",
|
||||
}
|
||||
jsonData, _ := json.Marshal(resetData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("POST /password-reset/reset - Invalid JSON", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer([]byte("{invalid json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordResetRoutes_HTTPMethods(t *testing.T) {
|
||||
router := setupTestRouter()
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
t.Run("Wrong HTTP methods should return 404 or 405", func(t *testing.T) {
|
||||
// Test wrong methods for password-reset/request (should be POST)
|
||||
req, _ := http.NewRequest("GET", "/password-reset/request", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
req, _ = http.NewRequest("PUT", "/password-reset/request", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Test wrong methods for password-reset/validate/:token (should be GET)
|
||||
req, _ = http.NewRequest("POST", "/password-reset/validate/some_token", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Test wrong methods for password-reset/reset (should be POST)
|
||||
req, _ = http.NewRequest("GET", "/password-reset/reset", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordResetRoutes_Security(t *testing.T) {
|
||||
router := setupTestRouter()
|
||||
|
||||
t.Run("Routes should not require authentication", func(t *testing.T) {
|
||||
testUser := setupTestUserForRouter(t)
|
||||
|
||||
// Test that password reset routes don't require Authorization header
|
||||
requestData := map[string]interface{}{
|
||||
"email": testUser.Email,
|
||||
"redirect_url": "http://localhost:3000",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Deliberately NOT setting Authorization header
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should work without auth
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
|
||||
t.Run("Email enumeration protection", func(t *testing.T) {
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
// Test with non-existent email
|
||||
requestData := map[string]interface{}{
|
||||
"email": "definitely.does.not.exist@example.com",
|
||||
"redirect_url": "http://localhost:3000",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return same success message to prevent email enumeration
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, response["message"], "Falls ein Account mit dieser E-Mail-Adresse existiert")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordResetRoutes_CORS(t *testing.T) {
|
||||
router := setupTestRouter()
|
||||
setupTestUserForRouter(t) // Setup database
|
||||
|
||||
t.Run("Routes should handle CORS preflight requests", func(t *testing.T) {
|
||||
// Test OPTIONS request for CORS preflight
|
||||
req, _ := http.NewRequest("OPTIONS", "/password-reset/request", nil)
|
||||
req.Header.Set("Origin", "http://localhost:3000")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
req.Header.Set("Access-Control-Request-Headers", "Content-Type")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should handle OPTIONS request appropriately
|
||||
// Note: Actual CORS headers would be set by middleware, not tested here
|
||||
// This just ensures the routes don't break with OPTIONS
|
||||
assert.NotEqual(t, http.StatusInternalServerError, w.Code)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# Password Reset Tests Documentation
|
||||
|
||||
Diese Dokumentation beschreibt die umfassenden Tests für das Password-Reset-System.
|
||||
|
||||
## Test-Übersicht
|
||||
|
||||
Das Password-Reset-System wurde mit 16 umfassenden Tests abgedeckt:
|
||||
|
||||
### 1. API-Handler-Tests
|
||||
|
||||
#### RequestPasswordReset Tests:
|
||||
- `TestRequestPasswordReset_Success` - Erfolgreiche Password-Reset-Anfrage mit `redirect_url`
|
||||
- `TestRequestPasswordReset_WithoutRedirectURL` - Erfolgreich ohne `redirect_url` (Fallback wird verwendet)
|
||||
- `TestRequestPasswordReset_NonExistentEmail` - Nicht existierende E-Mail (Sicherheitsverhalten: Keine Information preisgeben)
|
||||
- `TestRequestPasswordReset_InvalidEmail` - Ungültiges E-Mail-Format (Validation-Fehler)
|
||||
|
||||
#### ValidateResetToken Tests:
|
||||
- `TestValidateResetToken_Success` - Gültiger Token wird korrekt validiert
|
||||
- `TestValidateResetToken_InvalidToken` - Ungültiger Token wird abgelehnt
|
||||
- `TestValidateResetToken_ExpiredToken` - Abgelaufener Token wird abgelehnt
|
||||
|
||||
#### ResetPassword Tests:
|
||||
- `TestResetPassword_Success` - Erfolgreiches Zurücksetzen des Passworts
|
||||
- `TestResetPassword_InvalidToken` - Ungültiger Token wird abgelehnt
|
||||
- `TestResetPassword_ShortPassword` - Zu kurzes Passwort wird abgelehnt (< 6 Zeichen)
|
||||
- `TestResetPassword_ExpiredToken` - Abgelaufener Token wird abgelehnt
|
||||
|
||||
### 2. User-Model-Tests
|
||||
|
||||
#### Password-Reset-Hash Management:
|
||||
- `TestUser_SetPasswordResetHash` - Setzt Reset-Hash und Ablaufzeit (14 Tage)
|
||||
- `TestUser_ClearPasswordResetHash` - Entfernt Reset-Hash und Ablaufzeit
|
||||
- `TestUser_IsResetHashValid` - Validiert Reset-Hash gegen Zeit und Wert
|
||||
|
||||
#### Database-Query Tests:
|
||||
- `TestUser_FindByResetHash` - Findet User anhand gültigem Reset-Hash
|
||||
- `TestUser_FindByEmail` - Findet User anhand E-Mail-Adresse
|
||||
|
||||
## Test-Setup
|
||||
|
||||
### Database Migration
|
||||
- Jeder Test führt automatisch `MigrateStructure()` aus um sicherzustellen, dass die `reset_pw_hash` und `reset_pw_hash_expires` Felder existieren
|
||||
|
||||
### Test-Isolation
|
||||
- Eindeutige E-Mail-Adressen per Test über `rand.Intn(100000)` um UNIQUE-Constraint-Konflikte zu vermeiden
|
||||
- SQLite In-Memory-Datenbank für schnelle Test-Ausführung
|
||||
|
||||
### Test-Daten
|
||||
- Generierte Test-User mit MD5-gehashten Passwörtern (wie im echten System)
|
||||
- Realistische Reset-Token-Generierung und Zeitstempel
|
||||
|
||||
## Sicherheits-Tests
|
||||
|
||||
### E-Mail-Enumeration-Schutz
|
||||
- Tests stellen sicher, dass bei nicht-existierenden E-Mail-Adressen dieselbe Erfolgsmeldung zurückgegeben wird
|
||||
- Keine Information wird preisgegeben, ob ein Account existiert oder nicht
|
||||
|
||||
### Token-Sicherheit
|
||||
- Token-Ablauf wird korrekt geprüft (14 Tage)
|
||||
- Ungültige Tokens werden sicher abgelehnt
|
||||
- Token werden nach erfolgreichem Reset automatisch gelöscht
|
||||
|
||||
### Input-Validation
|
||||
- E-Mail-Format-Validierung
|
||||
- Passwort-Mindestlänge (6 Zeichen)
|
||||
- Token-Presence-Validation
|
||||
|
||||
## Test-Ausführung
|
||||
|
||||
```bash
|
||||
# Alle Password-Reset-Tests ausführen
|
||||
go test ./user -v -run ".*Reset.*"
|
||||
|
||||
# Einzelnen Test ausführen
|
||||
go test ./user -v -run "TestRequestPasswordReset_Success"
|
||||
|
||||
# Tests mit frischer Database bei jedem Lauf
|
||||
go test ./user -v -run ".*Reset.*" -count=1
|
||||
```
|
||||
|
||||
## Test-Ergebnisse
|
||||
|
||||
Alle 16 Tests bestehen erfolgreich:
|
||||
- ✅ API-Handler-Tests (11 Tests)
|
||||
- ✅ User-Model-Tests (5 Tests)
|
||||
- ✅ Sicherheits-Tests (integriert)
|
||||
- ✅ Database-Integration-Tests (integriert)
|
||||
|
||||
## Abgedeckte Szenarien
|
||||
|
||||
1. **Happy Path**: Normale Password-Reset-Workflows
|
||||
2. **Error Cases**: Ungültige Eingaben, abgelaufene Token
|
||||
3. **Security Cases**: E-Mail-Enumeration-Schutz, Token-Sicherheit
|
||||
4. **Edge Cases**: Fehlende Parameter, Fallback-Verhalten
|
||||
5. **Database Integration**: Migration, CRUD-Operationen, Constraints
|
||||
|
||||
Die Test-Suite gewährleistet eine vollständige Abdeckung aller Password-Reset-Funktionen mit fokus auf Sicherheit und Benutzerfreundlichkeit.
|
||||
@@ -0,0 +1,453 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bamort/database"
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestUser(t *testing.T) User {
|
||||
database.SetupTestDB()
|
||||
|
||||
// Migrate User structure to ensure reset password fields exist
|
||||
err := MigrateStructure()
|
||||
require.NoError(t, err, "Failed to migrate user structure")
|
||||
|
||||
// Generate unique email for each test to avoid conflicts
|
||||
randomSuffix := rand.Intn(100000)
|
||||
user := User{
|
||||
Username: fmt.Sprintf("testuser_reset_%d", randomSuffix),
|
||||
PasswordHash: "testpassword123",
|
||||
Email: fmt.Sprintf("test.reset.%d@example.com", randomSuffix),
|
||||
}
|
||||
|
||||
// Hash password like in RegisterUser
|
||||
hashedPassword := md5.Sum([]byte(user.PasswordHash))
|
||||
user.PasswordHash = hex.EncodeToString(hashedPassword[:])
|
||||
|
||||
err = user.Create()
|
||||
require.NoError(t, err, "Failed to create test user")
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func TestRequestPasswordReset_Success(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
// Setup Gin router
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/password-reset/request", RequestPasswordReset)
|
||||
|
||||
// Test data
|
||||
requestData := map[string]interface{}{
|
||||
"email": user.Email,
|
||||
"redirect_url": "http://localhost:3000",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
// Create request
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Execute request
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Assertions
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["message"], "Falls ein Account mit dieser E-Mail-Adresse existiert")
|
||||
|
||||
// Check that user has reset hash set in database
|
||||
var dbUser User
|
||||
err = dbUser.FindByEmail(user.Email)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, dbUser.ResetPwHash, "Reset hash should be set")
|
||||
assert.NotNil(t, dbUser.ResetPwHashExpires, "Reset expiry should be set")
|
||||
assert.True(t, dbUser.ResetPwHashExpires.After(time.Now()), "Reset expiry should be in future")
|
||||
}
|
||||
|
||||
func TestRequestPasswordReset_WithoutRedirectURL(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/password-reset/request", RequestPasswordReset)
|
||||
|
||||
// Test data without redirect_url
|
||||
requestData := map[string]interface{}{
|
||||
"email": user.Email,
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should still work with fallback URL
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestRequestPasswordReset_NonExistentEmail(t *testing.T) {
|
||||
database.SetupTestDB()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/password-reset/request", RequestPasswordReset)
|
||||
|
||||
requestData := map[string]interface{}{
|
||||
"email": "nonexistent@example.com",
|
||||
"redirect_url": "http://localhost:3000",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return success to prevent email enumeration
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, response["message"], "Falls ein Account mit dieser E-Mail-Adresse existiert")
|
||||
}
|
||||
|
||||
func TestRequestPasswordReset_InvalidEmail(t *testing.T) {
|
||||
database.SetupTestDB()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/password-reset/request", RequestPasswordReset)
|
||||
|
||||
requestData := map[string]interface{}{
|
||||
"email": "invalid-email-format",
|
||||
"redirect_url": "http://localhost:3000",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/request", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return 400 for invalid email format
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestValidateResetToken_Success(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
// Set reset hash
|
||||
resetHash := "test_reset_hash_123456789"
|
||||
err := user.SetPasswordResetHash(resetHash)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/password-reset/validate/:token", ValidateResetToken)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/password-reset/validate/"+resetHash, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, response["valid"].(bool))
|
||||
assert.Equal(t, user.Username, response["username"])
|
||||
assert.NotNil(t, response["expires"])
|
||||
}
|
||||
|
||||
func TestValidateResetToken_InvalidToken(t *testing.T) {
|
||||
database.SetupTestDB()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/password-reset/validate/:token", ValidateResetToken)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/password-reset/validate/invalid_token", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestValidateResetToken_ExpiredToken(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
// Set expired reset hash
|
||||
resetHash := "expired_reset_hash_123456789"
|
||||
expiredTime := time.Now().Add(-1 * time.Hour) // 1 hour ago
|
||||
user.ResetPwHash = &resetHash
|
||||
user.ResetPwHashExpires = &expiredTime
|
||||
err := user.Save()
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/password-reset/validate/:token", ValidateResetToken)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/password-reset/validate/"+resetHash, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestResetPassword_Success(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
originalPassword := user.PasswordHash
|
||||
|
||||
// Set reset hash
|
||||
resetHash := "test_reset_hash_for_password_change"
|
||||
err := user.SetPasswordResetHash(resetHash)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/password-reset/reset", ResetPassword)
|
||||
|
||||
requestData := map[string]interface{}{
|
||||
"token": resetHash,
|
||||
"new_password": "new_secure_password123",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Passwort erfolgreich zurückgesetzt", response["message"])
|
||||
|
||||
// Verify password was changed and reset hash was cleared
|
||||
var dbUser User
|
||||
err = dbUser.FindByEmail(user.Email)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, originalPassword, dbUser.PasswordHash, "Password should have changed")
|
||||
assert.Nil(t, dbUser.ResetPwHash, "Reset hash should be cleared")
|
||||
assert.Nil(t, dbUser.ResetPwHashExpires, "Reset expiry should be cleared")
|
||||
|
||||
// Verify new password hash
|
||||
expectedHash := md5.Sum([]byte("new_secure_password123"))
|
||||
expectedHashString := hex.EncodeToString(expectedHash[:])
|
||||
assert.Equal(t, expectedHashString, dbUser.PasswordHash, "New password hash should match")
|
||||
}
|
||||
|
||||
func TestResetPassword_InvalidToken(t *testing.T) {
|
||||
database.SetupTestDB()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/password-reset/reset", ResetPassword)
|
||||
|
||||
requestData := map[string]interface{}{
|
||||
"token": "invalid_token",
|
||||
"new_password": "new_secure_password123",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestResetPassword_ShortPassword(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
resetHash := "test_reset_hash_short_password"
|
||||
err := user.SetPasswordResetHash(resetHash)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/password-reset/reset", ResetPassword)
|
||||
|
||||
requestData := map[string]interface{}{
|
||||
"token": resetHash,
|
||||
"new_password": "123", // Too short
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestResetPassword_ExpiredToken(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
// Set expired reset hash
|
||||
resetHash := "expired_reset_hash_for_reset"
|
||||
expiredTime := time.Now().Add(-1 * time.Hour) // 1 hour ago
|
||||
user.ResetPwHash = &resetHash
|
||||
user.ResetPwHashExpires = &expiredTime
|
||||
err := user.Save()
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/password-reset/reset", ResetPassword)
|
||||
|
||||
requestData := map[string]interface{}{
|
||||
"token": resetHash,
|
||||
"new_password": "new_secure_password123",
|
||||
}
|
||||
jsonData, _ := json.Marshal(requestData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/password-reset/reset", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
// Test User model methods
|
||||
func TestUser_SetPasswordResetHash(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
resetHash := "test_hash_123456789"
|
||||
err := user.SetPasswordResetHash(resetHash)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, user.ResetPwHash)
|
||||
assert.Equal(t, resetHash, *user.ResetPwHash)
|
||||
assert.NotNil(t, user.ResetPwHashExpires)
|
||||
assert.True(t, user.ResetPwHashExpires.After(time.Now()))
|
||||
assert.True(t, user.ResetPwHashExpires.Before(time.Now().Add(15*24*time.Hour))) // Should be ~14 days
|
||||
}
|
||||
|
||||
func TestUser_ClearPasswordResetHash(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
// First set a reset hash
|
||||
resetHash := "test_hash_to_clear"
|
||||
err := user.SetPasswordResetHash(resetHash)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user.ResetPwHash)
|
||||
|
||||
// Then clear it
|
||||
err = user.ClearPasswordResetHash()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, user.ResetPwHash)
|
||||
assert.Nil(t, user.ResetPwHashExpires)
|
||||
}
|
||||
|
||||
func TestUser_IsResetHashValid(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
resetHash := "valid_test_hash_123"
|
||||
err := user.SetPasswordResetHash(resetHash)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test valid hash
|
||||
assert.True(t, user.IsResetHashValid(resetHash))
|
||||
|
||||
// Test invalid hash
|
||||
assert.False(t, user.IsResetHashValid("wrong_hash"))
|
||||
|
||||
// Test expired hash
|
||||
expiredTime := time.Now().Add(-1 * time.Hour)
|
||||
user.ResetPwHashExpires = &expiredTime
|
||||
assert.False(t, user.IsResetHashValid(resetHash))
|
||||
|
||||
// Test nil hash
|
||||
user.ResetPwHash = nil
|
||||
user.ResetPwHashExpires = nil
|
||||
assert.False(t, user.IsResetHashValid(resetHash))
|
||||
}
|
||||
|
||||
func TestUser_FindByResetHash(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
resetHash := "find_by_hash_test_123"
|
||||
err := user.SetPasswordResetHash(resetHash)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test finding valid hash
|
||||
var foundUser User
|
||||
err = foundUser.FindByResetHash(resetHash)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.UserID, foundUser.UserID)
|
||||
assert.Equal(t, user.Email, foundUser.Email)
|
||||
|
||||
// Test finding invalid hash
|
||||
var notFoundUser User
|
||||
err = notFoundUser.FindByResetHash("invalid_hash")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test finding expired hash
|
||||
expiredTime := time.Now().Add(-1 * time.Hour)
|
||||
user.ResetPwHashExpires = &expiredTime
|
||||
err = user.Save()
|
||||
require.NoError(t, err)
|
||||
|
||||
var expiredUser User
|
||||
err = expiredUser.FindByResetHash(resetHash)
|
||||
assert.Error(t, err) // Should not find expired token
|
||||
}
|
||||
|
||||
func TestUser_FindByEmail(t *testing.T) {
|
||||
user := setupTestUser(t)
|
||||
|
||||
// Test finding existing email
|
||||
var foundUser User
|
||||
err := foundUser.FindByEmail(user.Email)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.UserID, foundUser.UserID)
|
||||
assert.Equal(t, user.Username, foundUser.Username)
|
||||
|
||||
// Test finding non-existent email
|
||||
var notFoundUser User
|
||||
err = notFoundUser.FindByEmail("nonexistent@example.com")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user