Add delete account endpoint and file encryption at rest

Delete Account (Plan #2):
- DELETE /api/auth/account/ with password or "DELETE" confirmation
- Cascade delete across 15+ tables in correct FK order
- Auth provider detection (email/apple/google) for /auth/me/
- File cleanup after account deletion
- Handler + repository tests (12 tests)

Encryption at Rest (Plan #3):
- AES-256-GCM envelope encryption (per-file DEK wrapped by KEK)
- Encrypt on upload, auto-decrypt on serve via StorageService.ReadFile()
- MediaHandler serves decrypted files via c.Blob()
- TaskService email image loading uses ReadFile()
- cmd/migrate-encrypt CLI tool with --dry-run for existing files
- Encryption service + storage service tests (18 tests)
This commit is contained in:
Trey T
2026-03-26 10:41:01 -05:00
parent 72866e935e
commit 4abc57535e
22 changed files with 1675 additions and 82 deletions

View File

@@ -0,0 +1,217 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/testutil"
)
func setupDeleteAccountHandler(t *testing.T) (*AuthHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
},
}
authService := services.NewAuthService(userRepo, cfg)
handler := NewAuthHandler(authService, nil, nil)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestAuthHandler_DeleteAccount_EmailUser(t *testing.T) {
handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "deletetest", "delete@test.com", "password123")
// Create profile for the user
profile := &models.UserProfile{UserID: user.ID, Verified: true}
require.NoError(t, db.Create(profile).Error)
// Create auth token
testutil.CreateTestToken(t, db, user.ID)
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/account/", handler.DeleteAccount)
t.Run("successful deletion with correct password", func(t *testing.T) {
password := "password123"
req := map[string]interface{}{
"password": password,
}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response["message"], "Account deleted successfully")
// Verify user is actually deleted
var count int64
db.Model(&models.User{}).Where("id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), count)
// Verify profile is deleted
db.Model(&models.UserProfile{}).Where("user_id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), count)
// Verify auth token is deleted
db.Model(&models.AuthToken{}).Where("user_id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), count)
})
}
func TestAuthHandler_DeleteAccount_WrongPassword(t *testing.T) {
handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "wrongpw", "wrongpw@test.com", "password123")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/account/", handler.DeleteAccount)
t.Run("wrong password returns 401", func(t *testing.T) {
wrongPw := "wrongpassword"
req := map[string]interface{}{
"password": wrongPw,
}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}
func TestAuthHandler_DeleteAccount_MissingPassword(t *testing.T) {
handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "nopw", "nopw@test.com", "password123")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/account/", handler.DeleteAccount)
t.Run("missing password returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_DeleteAccount_SocialAuthUser(t *testing.T) {
handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "appleuser", "apple@test.com", "randompassword")
// Create Apple social auth record
appleAuth := &models.AppleSocialAuth{
UserID: user.ID,
AppleID: "apple_sub_123",
Email: "apple@test.com",
}
require.NoError(t, db.Create(appleAuth).Error)
// Create profile
profile := &models.UserProfile{UserID: user.ID, Verified: true}
require.NoError(t, db.Create(profile).Error)
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/account/", handler.DeleteAccount)
t.Run("successful deletion with DELETE confirmation", func(t *testing.T) {
confirmation := "DELETE"
req := map[string]interface{}{
"confirmation": confirmation,
}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
// Verify user is deleted
var count int64
db.Model(&models.User{}).Where("id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), count)
// Verify apple auth is deleted
db.Model(&models.AppleSocialAuth{}).Where("user_id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), count)
})
}
func TestAuthHandler_DeleteAccount_SocialAuthMissingConfirmation(t *testing.T) {
handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "googleuser", "google@test.com", "randompassword")
// Create Google social auth record
googleAuth := &models.GoogleSocialAuth{
UserID: user.ID,
GoogleID: "google_sub_456",
Email: "google@test.com",
}
require.NoError(t, db.Create(googleAuth).Error)
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/account/", handler.DeleteAccount)
t.Run("missing confirmation returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("wrong confirmation returns 400", func(t *testing.T) {
wrongConfirmation := "delete"
req := map[string]interface{}{
"confirmation": wrongConfirmation,
}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_DeleteAccount_Unauthenticated(t *testing.T) {
handler, e, _ := setupDeleteAccountHandler(t)
// No auth middleware - unauthenticated request
e.DELETE("/api/auth/account/", handler.DeleteAccount)
t.Run("unauthenticated request returns 401", func(t *testing.T) {
req := map[string]interface{}{
"password": "password123",
}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}