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

@@ -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)
})
}