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:
164
internal/services/storage_service_test.go
Normal file
164
internal/services/storage_service_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
)
|
||||
|
||||
func setupTestStorage(t *testing.T, encrypt bool) (*StorageService, string) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := &config.StorageConfig{
|
||||
UploadDir: tmpDir,
|
||||
BaseURL: "/uploads",
|
||||
MaxFileSize: 10 * 1024 * 1024,
|
||||
AllowedTypes: "image/jpeg,image/png,application/pdf",
|
||||
}
|
||||
|
||||
svc, err := NewStorageService(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create storage service: %v", err)
|
||||
}
|
||||
|
||||
if encrypt {
|
||||
encSvc, err := NewEncryptionService(validTestKey())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create encryption service: %v", err)
|
||||
}
|
||||
svc.SetEncryptionService(encSvc)
|
||||
}
|
||||
|
||||
return svc, tmpDir
|
||||
}
|
||||
|
||||
func TestReadFile_PlainFile(t *testing.T) {
|
||||
svc, tmpDir := setupTestStorage(t, false)
|
||||
|
||||
// Write a plain file
|
||||
content := []byte("hello world")
|
||||
dir := filepath.Join(tmpDir, "images")
|
||||
if err := os.WriteFile(filepath.Join(dir, "test.jpg"), content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, mimeType, err := svc.ReadFile("/uploads/images/test.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(data, content) {
|
||||
t.Fatalf("data mismatch: got %q, want %q", data, content)
|
||||
}
|
||||
|
||||
if mimeType == "" {
|
||||
t.Fatal("expected non-empty MIME type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFile_EncryptedFile(t *testing.T) {
|
||||
svc, tmpDir := setupTestStorage(t, true)
|
||||
|
||||
// Encrypt and write a file manually (simulating what Upload does)
|
||||
originalContent := []byte("sensitive document content here - must be long enough for detection")
|
||||
encSvc, _ := NewEncryptionService(validTestKey())
|
||||
encrypted, err := encSvc.Encrypt(originalContent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(tmpDir, "documents")
|
||||
if err := os.WriteFile(filepath.Join(dir, "test.pdf.enc"), encrypted, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ReadFile should find the .enc file and decrypt it
|
||||
data, _, err := svc.ReadFile("/uploads/documents/test.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(data, originalContent) {
|
||||
t.Fatalf("decrypted data mismatch: got %q, want %q", data, originalContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFile_EncFilePreferredOverPlain(t *testing.T) {
|
||||
svc, tmpDir := setupTestStorage(t, true)
|
||||
|
||||
encSvc, _ := NewEncryptionService(validTestKey())
|
||||
|
||||
plainContent := []byte("plain version")
|
||||
encContent := []byte("encrypted version - the correct one")
|
||||
encrypted, _ := encSvc.Encrypt(encContent)
|
||||
|
||||
dir := filepath.Join(tmpDir, "images")
|
||||
os.WriteFile(filepath.Join(dir, "photo.jpg"), plainContent, 0644)
|
||||
os.WriteFile(filepath.Join(dir, "photo.jpg.enc"), encrypted, 0644)
|
||||
|
||||
// Should prefer the .enc file
|
||||
data, _, err := svc.ReadFile("/uploads/images/photo.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(data, encContent) {
|
||||
t.Fatalf("expected encrypted version content, got %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFile_EmptyURL(t *testing.T) {
|
||||
svc, _ := setupTestStorage(t, false)
|
||||
|
||||
_, _, err := svc.ReadFile("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFile_MissingFile(t *testing.T) {
|
||||
svc, _ := setupTestStorage(t, false)
|
||||
|
||||
_, _, err := svc.ReadFile("/uploads/images/nonexistent.jpg")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_HandlesEncAndPlain(t *testing.T) {
|
||||
svc, tmpDir := setupTestStorage(t, false)
|
||||
|
||||
dir := filepath.Join(tmpDir, "images")
|
||||
|
||||
// Create both plain and .enc files
|
||||
os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("plain"), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "photo.jpg.enc"), []byte("encrypted"), 0644)
|
||||
|
||||
err := svc.Delete("/uploads/images/photo.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
// Both should be gone
|
||||
if _, err := os.Stat(filepath.Join(dir, "photo.jpg")); !os.IsNotExist(err) {
|
||||
t.Fatal("plain file should be deleted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.enc")); !os.IsNotExist(err) {
|
||||
t.Fatal("encrypted file should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_NonexistentFile(t *testing.T) {
|
||||
svc, _ := setupTestStorage(t, false)
|
||||
|
||||
// Should not error for non-existent files
|
||||
err := svc.Delete("/uploads/images/nope.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("Delete should not error for non-existent file: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user