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:
@@ -34,6 +34,12 @@ func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
// DB returns the underlying *gorm.DB connection. This is useful when callers
|
||||
// need to pass the connection (e.g., a transaction) to methods that accept *gorm.DB.
|
||||
func (r *UserRepository) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// Transaction runs fn inside a database transaction. The callback receives a
|
||||
// new UserRepository backed by the transaction so all operations within fn
|
||||
// share the same transactional connection.
|
||||
@@ -509,6 +515,195 @@ func (r *UserRepository) FindProfilesInSharedResidences(userID uint) ([]models.U
|
||||
return profiles, err
|
||||
}
|
||||
|
||||
// --- Auth Provider Detection ---
|
||||
|
||||
// FindAuthProvider determines the auth provider for a user.
|
||||
// Returns "apple", "google", or "email".
|
||||
func (r *UserRepository) FindAuthProvider(userID uint) (string, error) {
|
||||
var count int64
|
||||
if err := r.db.Model(&models.AppleSocialAuth{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count > 0 {
|
||||
return "apple", nil
|
||||
}
|
||||
|
||||
if err := r.db.Model(&models.GoogleSocialAuth{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count > 0 {
|
||||
return "google", nil
|
||||
}
|
||||
|
||||
return "email", nil
|
||||
}
|
||||
|
||||
// --- Account Deletion ---
|
||||
|
||||
// DeleteUserCascade deletes a user and all related records in dependency order.
|
||||
// Should be called on a repository backed by a transaction (via Transaction callback).
|
||||
// Returns a list of file URLs that need to be deleted from disk after the transaction commits.
|
||||
func (r *UserRepository) DeleteUserCascade(userID uint) ([]string, error) {
|
||||
var fileURLs []string
|
||||
db := r.db
|
||||
|
||||
// 1. Push notification devices
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.APNSDevice{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.GCMDevice{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Notifications
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.Notification{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Notification preferences
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.NotificationPreference{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Task reminder logs
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.TaskReminderLog{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Find residences owned by user
|
||||
var ownedResidences []models.Residence
|
||||
if err := db.Where("owner_id = ?", userID).Find(&ownedResidences).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, residence := range ownedResidences {
|
||||
// Collect file URLs before deleting
|
||||
|
||||
// Task completion images (via completion_id -> task_id -> residence_id)
|
||||
var completionImageURLs []string
|
||||
db.Model(&models.TaskCompletionImage{}).
|
||||
Joins("JOIN task_taskcompletion ON task_taskcompletion.id = task_taskcompletionimage.completion_id").
|
||||
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
|
||||
Where("task_task.residence_id = ?", residence.ID).
|
||||
Pluck("task_taskcompletionimage.image_url", &completionImageURLs)
|
||||
fileURLs = append(fileURLs, completionImageURLs...)
|
||||
|
||||
// Delete task completion images
|
||||
db.Exec(`DELETE FROM task_taskcompletionimage WHERE completion_id IN (
|
||||
SELECT tc.id FROM task_taskcompletion tc
|
||||
JOIN task_task t ON t.id = tc.task_id
|
||||
WHERE t.residence_id = ?
|
||||
)`, residence.ID)
|
||||
|
||||
// Delete task completions
|
||||
db.Exec(`DELETE FROM task_taskcompletion WHERE task_id IN (
|
||||
SELECT id FROM task_task WHERE residence_id = ?
|
||||
)`, residence.ID)
|
||||
|
||||
// Document images (via document_id -> residence_id)
|
||||
var docImageURLs []string
|
||||
db.Model(&models.DocumentImage{}).
|
||||
Joins("JOIN task_document ON task_document.id = task_documentimage.document_id").
|
||||
Where("task_document.residence_id = ?", residence.ID).
|
||||
Pluck("task_documentimage.image_url", &docImageURLs)
|
||||
fileURLs = append(fileURLs, docImageURLs...)
|
||||
|
||||
// Delete document images
|
||||
db.Exec(`DELETE FROM task_documentimage WHERE document_id IN (
|
||||
SELECT id FROM task_document WHERE residence_id = ?
|
||||
)`, residence.ID)
|
||||
|
||||
// Document file URLs
|
||||
var docFileURLs []string
|
||||
db.Model(&models.Document{}).Where("residence_id = ?", residence.ID).Pluck("file_url", &docFileURLs)
|
||||
fileURLs = append(fileURLs, docFileURLs...)
|
||||
|
||||
// Delete documents
|
||||
if err := db.Where("residence_id = ?", residence.ID).Delete(&models.Document{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete tasks
|
||||
if err := db.Where("residence_id = ?", residence.ID).Delete(&models.Task{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete contractor specialties (many-to-many join table)
|
||||
db.Exec(`DELETE FROM task_contractor_specialties WHERE contractor_id IN (
|
||||
SELECT id FROM task_contractor WHERE residence_id = ?
|
||||
)`, residence.ID)
|
||||
|
||||
// Delete contractors
|
||||
if err := db.Where("residence_id = ?", residence.ID).Delete(&models.Contractor{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete share codes
|
||||
if err := db.Where("residence_id = ?", residence.ID).Delete(&models.ResidenceShareCode{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remove residence membership records (many-to-many join table)
|
||||
db.Exec("DELETE FROM residence_residence_users WHERE residence_id = ?", residence.ID)
|
||||
|
||||
// Delete the residence itself
|
||||
if err := db.Delete(&residence).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Remove user from shared residences they don't own (membership only)
|
||||
db.Exec("DELETE FROM residence_residence_users WHERE user_id = ?", userID)
|
||||
|
||||
// 7. Subscription
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.UserSubscription{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8. Social auth records
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.AppleSocialAuth{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.GoogleSocialAuth{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 9. Confirmation codes
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.ConfirmationCode{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 10. Password reset codes
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.PasswordResetCode{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 11. Auth tokens
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.AuthToken{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 12. User profile
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.UserProfile{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 13. User
|
||||
if err := db.Where("id = ?", userID).Delete(&models.User{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter out empty URLs
|
||||
var cleanURLs []string
|
||||
for _, url := range fileURLs {
|
||||
if url != "" {
|
||||
cleanURLs = append(cleanURLs, url)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanURLs, nil
|
||||
}
|
||||
|
||||
// --- Apple Social Auth Methods ---
|
||||
|
||||
// FindByAppleID finds an Apple social auth by Apple ID
|
||||
|
||||
@@ -187,3 +187,169 @@ func TestUserRepository_GetOrCreateProfile(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, profile1.ID, profile2.ID)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindAuthProvider(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
t.Run("email user", func(t *testing.T) {
|
||||
user := testutil.CreateTestUser(t, db, "emailuser", "email@test.com", "password123")
|
||||
provider, err := repo.FindAuthProvider(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "email", provider)
|
||||
})
|
||||
|
||||
t.Run("apple user", func(t *testing.T) {
|
||||
user := testutil.CreateTestUser(t, db, "appleuser", "apple@test.com", "password123")
|
||||
appleAuth := &models.AppleSocialAuth{
|
||||
UserID: user.ID,
|
||||
AppleID: "apple_sub_test",
|
||||
Email: "apple@test.com",
|
||||
}
|
||||
require.NoError(t, db.Create(appleAuth).Error)
|
||||
|
||||
provider, err := repo.FindAuthProvider(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "apple", provider)
|
||||
})
|
||||
|
||||
t.Run("google user", func(t *testing.T) {
|
||||
user := testutil.CreateTestUser(t, db, "googleuser", "google@test.com", "password123")
|
||||
googleAuth := &models.GoogleSocialAuth{
|
||||
UserID: user.ID,
|
||||
GoogleID: "google_sub_test",
|
||||
Email: "google@test.com",
|
||||
}
|
||||
require.NoError(t, db.Create(googleAuth).Error)
|
||||
|
||||
provider, err := repo.FindAuthProvider(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "google", provider)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserRepository_DeleteUserCascade(t *testing.T) {
|
||||
t.Run("deletes user with no residences", func(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "deletebare", "deletebare@test.com", "password123")
|
||||
|
||||
// Create profile and token
|
||||
profile := &models.UserProfile{UserID: user.ID, Verified: true}
|
||||
require.NoError(t, db.Create(profile).Error)
|
||||
_, err := models.GetOrCreateToken(db, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var fileURLs []string
|
||||
txErr := repo.Transaction(func(txRepo *UserRepository) error {
|
||||
urls, err := txRepo.DeleteUserCascade(user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileURLs = urls
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, txErr)
|
||||
assert.Empty(t, fileURLs)
|
||||
|
||||
// Verify user is gone
|
||||
var count int64
|
||||
db.Model(&models.User{}).Where("id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Verify profile is gone
|
||||
db.Model(&models.UserProfile{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Verify token is gone
|
||||
db.Model(&models.AuthToken{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
})
|
||||
|
||||
t.Run("returns file URLs for cleanup", func(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "deletefiles", "deletefiles@test.com", "password123")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test Home")
|
||||
|
||||
// Create document with file
|
||||
doc := &models.Document{
|
||||
ResidenceID: residence.ID,
|
||||
CreatedByID: user.ID,
|
||||
Title: "Test Doc",
|
||||
FileURL: "/uploads/documents/test.pdf",
|
||||
}
|
||||
require.NoError(t, db.Create(doc).Error)
|
||||
|
||||
// Create document image
|
||||
docImage := &models.DocumentImage{
|
||||
DocumentID: doc.ID,
|
||||
ImageURL: "/uploads/images/docimg.jpg",
|
||||
}
|
||||
require.NoError(t, db.Create(docImage).Error)
|
||||
|
||||
var fileURLs []string
|
||||
txErr := repo.Transaction(func(txRepo *UserRepository) error {
|
||||
urls, err := txRepo.DeleteUserCascade(user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileURLs = urls
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, txErr)
|
||||
|
||||
// Should return the file URLs
|
||||
assert.Contains(t, fileURLs, "/uploads/documents/test.pdf")
|
||||
assert.Contains(t, fileURLs, "/uploads/images/docimg.jpg")
|
||||
|
||||
// Verify everything deleted
|
||||
var count int64
|
||||
db.Model(&models.User{}).Where("id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
db.Model(&models.Residence{}).Where("id = ?", residence.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
db.Model(&models.Document{}).Where("id = ?", doc.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
})
|
||||
|
||||
t.Run("handles user with owned and shared residences", func(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
owner := testutil.CreateTestUser(t, db, "deleteowner", "deleteowner@test.com", "password123")
|
||||
otherUser := testutil.CreateTestUser(t, db, "otheruser", "other@test.com", "password123")
|
||||
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other Home")
|
||||
|
||||
// Owner's residence
|
||||
ownedResidence := testutil.CreateTestResidence(t, db, owner.ID, "Owner Home")
|
||||
|
||||
// Add owner as member of other user's residence
|
||||
db.Exec("INSERT INTO residence_residence_users (residence_id, user_id) VALUES (?, ?)", otherResidence.ID, owner.ID)
|
||||
|
||||
txErr := repo.Transaction(func(txRepo *UserRepository) error {
|
||||
_, err := txRepo.DeleteUserCascade(owner.ID)
|
||||
return err
|
||||
})
|
||||
require.NoError(t, txErr)
|
||||
|
||||
// Owner's residence should be deleted
|
||||
var count int64
|
||||
db.Model(&models.Residence{}).Where("id = ?", ownedResidence.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Other user's residence should still exist
|
||||
db.Model(&models.Residence{}).Where("id = ?", otherResidence.ID).Count(&count)
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
// Owner should no longer be a member of other's residence
|
||||
db.Raw("SELECT COUNT(*) FROM residence_residence_users WHERE user_id = ?", owner.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Other user should still exist
|
||||
db.Model(&models.User{}).Where("id = ?", otherUser.ID).Count(&count)
|
||||
assert.Equal(t, int64(1), count)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user