616 lines
24 KiB
Go
616 lines
24 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/gochat/gochat/internal/config"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/security"
|
|
)
|
|
|
|
var (
|
|
testPNG = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}
|
|
testMP3 = []byte{'I', 'D', '3', 4, 0, 0, 0, 0, 0, 0}
|
|
)
|
|
|
|
// --- MIME detection helper tests ---
|
|
|
|
func TestDetectUploadMIMEFromFilename(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
want string
|
|
}{
|
|
{"photo.png", "image/png"},
|
|
{"photo.jpg", "image/jpeg"},
|
|
{"photo.jpeg", "image/jpeg"},
|
|
{"anim.gif", "image/gif"},
|
|
{"modern.webp", "image/webp"},
|
|
{"icon.svg", "image/svg+xml"},
|
|
{"song.mp3", "audio/mpeg"},
|
|
{"audio.ogg", "audio/ogg"},
|
|
{"sound.wav", "audio/wav"},
|
|
{"clip.webm", "audio/webm"},
|
|
{"video.mp4", "video/mp4"},
|
|
{"doc.pdf", "application/pdf"},
|
|
{"data.csv", "text/csv"},
|
|
{"readme.txt", "text/plain"},
|
|
{"sheet.xls", "application/vnd.ms-excel"},
|
|
{"sheet.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
|
|
{"letter.doc", "application/msword"},
|
|
{"letter.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
|
{"unknown.xyz", "application/octet-stream"},
|
|
{"noext", "application/octet-stream"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.filename, func(t *testing.T) {
|
|
got := detectUploadMIMEFromFilename(tt.filename)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCategorizeUploadMIME(t *testing.T) {
|
|
tests := []struct {
|
|
mimeType string
|
|
want string
|
|
}{
|
|
{"image/png", "image"},
|
|
{"image/jpeg", "image"},
|
|
{"image/gif", "image"},
|
|
{"audio/mpeg", "audio"},
|
|
{"audio/ogg", "audio"},
|
|
{"video/mp4", "video"},
|
|
{"application/pdf", "file"},
|
|
{"text/csv", "file"},
|
|
{"text/plain", "file"},
|
|
{"application/octet-stream", ""}, // unsupported category
|
|
{"application/zip", ""}, // unsupported
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.mimeType, func(t *testing.T) {
|
|
got := categorizeUploadMIME(tt.mimeType)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsUploadMIMEAllowed(t *testing.T) {
|
|
// Allowed MIME types per category
|
|
assert.True(t, isUploadMIMEAllowed("image", "image/png"))
|
|
assert.True(t, isUploadMIMEAllowed("image", "image/jpeg"))
|
|
assert.True(t, isUploadMIMEAllowed("image", "image/gif"))
|
|
assert.True(t, isUploadMIMEAllowed("audio", "audio/mpeg"))
|
|
assert.True(t, isUploadMIMEAllowed("audio", "audio/ogg"))
|
|
assert.True(t, isUploadMIMEAllowed("video", "video/mp4"))
|
|
assert.True(t, isUploadMIMEAllowed("file", "application/pdf"))
|
|
assert.True(t, isUploadMIMEAllowed("file", "text/csv"))
|
|
assert.False(t, isUploadMIMEAllowed("file", "application/octet-stream"))
|
|
|
|
// Disallowed MIME types
|
|
assert.False(t, isUploadMIMEAllowed("image", "audio/mpeg")) // wrong category
|
|
assert.False(t, isUploadMIMEAllowed("video", "image/png")) // wrong category
|
|
assert.False(t, isUploadMIMEAllowed("unknown", "image/png")) // unknown category
|
|
}
|
|
|
|
// --- Service integration tests using SQLite ---
|
|
|
|
func setupUploadServiceTest(t *testing.T) (*gorm.DB, *UploadService, string) {
|
|
t.Helper()
|
|
|
|
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err, "failed to open test DB")
|
|
|
|
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.Contact{}, &model.ContactInbox{}, &model.DirectUpload{}), "failed to auto-migrate upload models")
|
|
account := &model.Account{Name: "Upload Test", Status: "active"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
inbox := &model.Inbox{AccountID: account.ID, Name: "Upload Inbox", ChannelType: "web_widget", ChannelConfig: `{"website_token":"test-website"}`, Enabled: true}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
contact := &model.Contact{AccountID: account.ID, Name: "Upload Contact"}
|
|
require.NoError(t, db.Create(contact).Error)
|
|
require.NoError(t, db.Create(&model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "test-widget"}).Error)
|
|
|
|
t.Cleanup(func() {
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.Close()
|
|
})
|
|
|
|
// Create temp directory for file storage
|
|
tmpDir := t.TempDir()
|
|
|
|
cfg := &config.Config{
|
|
Storage: config.StorageConfig{
|
|
Provider: "local",
|
|
LocalPath: tmpDir,
|
|
MaxFileSize: 20 * 1024 * 1024, // 20MB
|
|
},
|
|
}
|
|
|
|
directUploadRepo := repository.NewDirectUploadRepo(db)
|
|
uploadService := NewUploadService(directUploadRepo, cfg).
|
|
WithWidgetAuth(repository.NewInboxRepo(db), repository.NewContactInboxRepo(db)).
|
|
WithAccessDB(db)
|
|
|
|
return db, uploadService, tmpDir
|
|
}
|
|
|
|
// createTestFileHeader builds a real *multipart.FileHeader from content.
|
|
// It constructs a real HTTP multipart request, parses it, and extracts the FileHeader.
|
|
func createTestFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {
|
|
t.Helper()
|
|
|
|
body := &strings.Builder{}
|
|
writer := multipart.NewWriter(body)
|
|
part, err := writer.CreateFormFile("file", filename)
|
|
require.NoError(t, err, "CreateFormFile failed")
|
|
_, err = part.Write(content)
|
|
require.NoError(t, err, "write content failed")
|
|
require.NoError(t, writer.Close(), "close multipart writer failed")
|
|
|
|
req, err := http.NewRequest("POST", "/", strings.NewReader(body.String()))
|
|
require.NoError(t, err, "create request failed")
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
// FormFile returns (multipart.File, *multipart.FileHeader, error)
|
|
_, fileHeader, err := req.FormFile("file")
|
|
require.NoError(t, err, "FormFile failed")
|
|
return fileHeader
|
|
}
|
|
|
|
func TestUploadService_AccountUpload_Success(t *testing.T) {
|
|
_, svc, tmpDir := setupUploadServiceTest(t)
|
|
|
|
fileHeader := createTestFileHeader(t, "test_image.png", testPNG)
|
|
|
|
result, svcErr := svc.AccountUpload(context.Background(), 1, AccountUploadRequest{
|
|
FileHeader: fileHeader,
|
|
})
|
|
require.NoError(t, svcErr, "AccountUpload should succeed")
|
|
|
|
assert.NotEmpty(t, result.UploadUUID)
|
|
assert.Equal(t, "test_image.png", result.OriginalName)
|
|
assert.Equal(t, "image", result.FileType)
|
|
assert.Equal(t, "image/png", result.MimeType)
|
|
assert.Equal(t, "pending", result.Status)
|
|
assert.NotZero(t, result.ExpiresAt)
|
|
|
|
// Verify file was saved on disk
|
|
files, _ := filepath.Glob(filepath.Join(tmpDir, "account", "1", "*.png"))
|
|
assert.GreaterOrEqual(t, len(files), 1, "file should be saved on disk")
|
|
}
|
|
|
|
func TestUploadSharedStorageSurvivesReplicaRecreation(t *testing.T) {
|
|
_, svc, storagePath := setupUploadServiceTest(t)
|
|
content := []byte("shared attachment")
|
|
upload, err := svc.AccountUpload(context.Background(), 1, AccountUploadRequest{
|
|
FileHeader: createTestFileHeader(t, "shared.txt", content),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
for range 2 {
|
|
replica := gin.New()
|
|
replica.StaticFS("/uploads", gin.Dir(storagePath, false))
|
|
response := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodGet, upload.FileURL, nil)
|
|
replica.ServeHTTP(response, request)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
assert.Equal(t, content, response.Body.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestUploadService_AccountUpload_NoAccountID(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
|
|
_, svcErr := svc.AccountUpload(context.Background(), 0, AccountUploadRequest{
|
|
FileHeader: nil,
|
|
})
|
|
assert.Error(t, svcErr)
|
|
assert.Contains(t, svcErr.Error(), "account_id is required")
|
|
}
|
|
|
|
func TestUploadService_AccountUpload_NoFile(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
|
|
_, svcErr := svc.AccountUpload(context.Background(), 1, AccountUploadRequest{
|
|
FileHeader: nil,
|
|
})
|
|
assert.Error(t, svcErr)
|
|
assert.Contains(t, svcErr.Error(), "file is required")
|
|
}
|
|
|
|
func TestUploadService_WidgetDirectUpload_Success(t *testing.T) {
|
|
_, svc, tmpDir := setupUploadServiceTest(t)
|
|
|
|
fileHeader := createTestFileHeader(t, "visitor_audio.mp3", testMP3)
|
|
|
|
result, svcErr := svc.WidgetDirectUpload(context.Background(), WidgetDirectUploadRequest{
|
|
WebsiteToken: "test-website",
|
|
AuthToken: "test-widget",
|
|
FileHeader: fileHeader,
|
|
})
|
|
require.NoError(t, svcErr, "WidgetDirectUpload should succeed")
|
|
|
|
assert.NotEmpty(t, result.UploadUUID)
|
|
assert.Equal(t, "visitor_audio.mp3", result.OriginalName)
|
|
assert.Equal(t, "audio", result.FileType)
|
|
assert.Equal(t, "audio/mpeg", result.MimeType)
|
|
assert.Equal(t, "pending", result.Status)
|
|
|
|
// Widget uploads should go to "widget_direct" subdirectory
|
|
files, _ := filepath.Glob(filepath.Join(tmpDir, "widget_direct", "1", "*.mp3"))
|
|
assert.GreaterOrEqual(t, len(files), 1, "widget file should be saved in widget_direct dir")
|
|
}
|
|
|
|
func TestUploadService_WidgetDirectUpload_NoFile(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
|
|
_, svcErr := svc.WidgetDirectUpload(context.Background(), WidgetDirectUploadRequest{
|
|
FileHeader: nil,
|
|
})
|
|
assert.Error(t, svcErr)
|
|
assert.Contains(t, svcErr.Error(), "file is required")
|
|
}
|
|
|
|
func TestUploadService_AccountUpload_UnsupportedFileType(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
|
|
// .exe gets application/octet-stream MIME via detectUploadMIMEFromFilename
|
|
// categorizeUploadMIME("application/octet-stream") returns "" (unsupported)
|
|
// So processUpload should fail with "unsupported file type"
|
|
fileHeader := createTestFileHeader(t, "malware.exe", []byte("fake exe content"))
|
|
|
|
_, svcErr := svc.AccountUpload(context.Background(), 1, AccountUploadRequest{
|
|
FileHeader: fileHeader,
|
|
})
|
|
assert.Error(t, svcErr)
|
|
assert.Contains(t, svcErr.Error(), "unsupported file type")
|
|
}
|
|
|
|
// --- Account Direct Upload service tests ---
|
|
// Reference: Chatwoot POST /api/v1/accounts/:account_id/direct_uploads
|
|
|
|
func TestUploadService_AccountDirectUpload_Success(t *testing.T) {
|
|
_, svc, tmpDir := setupUploadServiceTest(t)
|
|
|
|
fileHeader := createTestFileHeader(t, "staged_image.png", testPNG)
|
|
|
|
result, svcErr := svc.AccountDirectUpload(context.Background(), 1, AccountDirectUploadRequest{
|
|
FileHeader: fileHeader,
|
|
})
|
|
require.NoError(t, svcErr, "AccountDirectUpload should succeed")
|
|
|
|
assert.NotEmpty(t, result.UploadUUID)
|
|
assert.Equal(t, "staged_image.png", result.OriginalName)
|
|
assert.Equal(t, "image", result.FileType)
|
|
assert.Equal(t, "image/png", result.MimeType)
|
|
assert.Equal(t, "pending", result.Status)
|
|
assert.NotZero(t, result.ExpiresAt)
|
|
|
|
// Verify file was saved on disk (account-scoped, same subdirectory as AccountUpload)
|
|
files, _ := filepath.Glob(filepath.Join(tmpDir, "account", "1", "*.png"))
|
|
assert.GreaterOrEqual(t, len(files), 1, "file should be saved on disk in account/1/")
|
|
}
|
|
|
|
func TestUploadService_AccountDirectUpload_NoAccountID(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
|
|
fileHeader := createTestFileHeader(t, "test.png", testPNG)
|
|
|
|
_, svcErr := svc.AccountDirectUpload(context.Background(), 0, AccountDirectUploadRequest{
|
|
FileHeader: fileHeader,
|
|
})
|
|
assert.Error(t, svcErr)
|
|
assert.Contains(t, svcErr.Error(), "account_id is required")
|
|
}
|
|
|
|
func TestUploadService_AccountDirectUpload_NoFile(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
|
|
_, svcErr := svc.AccountDirectUpload(context.Background(), 1, AccountDirectUploadRequest{
|
|
FileHeader: nil,
|
|
})
|
|
assert.Error(t, svcErr)
|
|
assert.Contains(t, svcErr.Error(), "file is required")
|
|
}
|
|
|
|
func TestUploadService_AccountDirectUpload_UnsupportedFileType(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
|
|
fileHeader := createTestFileHeader(t, "malware.exe", []byte("fake exe content"))
|
|
|
|
_, svcErr := svc.AccountDirectUpload(context.Background(), 1, AccountDirectUploadRequest{
|
|
FileHeader: fileHeader,
|
|
})
|
|
assert.Error(t, svcErr)
|
|
assert.Contains(t, svcErr.Error(), "unsupported file type")
|
|
}
|
|
|
|
func TestUploadService_RejectsSpoofedMIMEAndSVG(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
filename string
|
|
content []byte
|
|
}{
|
|
{name: "forged png", filename: "attack.png", content: []byte("<script>alert(1)</script>")},
|
|
{name: "active svg", filename: "attack.svg", content: []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := svc.AccountUpload(context.Background(), 1, AccountUploadRequest{FileHeader: createTestFileHeader(t, test.filename, test.content)})
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUploadService_RejectsTextPlainSVGAndXMLAcrossUploadPaths(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
payloads := map[string][]byte{
|
|
"svg": []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`),
|
|
"xml": []byte(`<root><script>alert(1)</script></root>`),
|
|
}
|
|
paths := map[string]func(*testing.T, []byte) error{
|
|
"direct": func(t *testing.T, payload []byte) error {
|
|
file := createTestFileHeader(t, "attack.txt", payload)
|
|
file.Header.Set("Content-Type", "text/plain")
|
|
_, err := svc.AccountDirectUpload(context.Background(), 1, AccountDirectUploadRequest{FileHeader: file})
|
|
return err
|
|
},
|
|
"widget": func(t *testing.T, payload []byte) error {
|
|
file := createTestFileHeader(t, "attack.txt", payload)
|
|
file.Header.Set("Content-Type", "text/plain")
|
|
_, err := svc.WidgetDirectUpload(context.Background(), WidgetDirectUploadRequest{
|
|
WebsiteToken: "test-website",
|
|
AuthToken: "test-widget",
|
|
FileHeader: file,
|
|
})
|
|
return err
|
|
},
|
|
"remote": func(t *testing.T, payload []byte) error {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write(payload)
|
|
}))
|
|
defer server.Close()
|
|
svc.fetchClient = security.NewSafeHTTPClient(security.SSRFConfig{MaxRedirects: 3})
|
|
_, err := svc.AccountUploadFromURL(context.Background(), 1, server.URL+"/attack.txt")
|
|
return err
|
|
},
|
|
}
|
|
|
|
for payloadName, payload := range payloads {
|
|
for pathName, upload := range paths {
|
|
t.Run(payloadName+"/"+pathName, func(t *testing.T) {
|
|
require.ErrorContains(t, upload(t, payload), "active markup")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUploadService_AccountUploadFromURLRejectsPrivateTargets(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
for _, target := range []string{
|
|
"http://127.0.0.1/file.png",
|
|
"http://10.0.0.1/file.png",
|
|
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
|
|
} {
|
|
_, err := svc.AccountUploadFromURL(context.Background(), 1, target)
|
|
require.Error(t, err, target)
|
|
assert.Contains(t, err.Error(), "SSRF", target)
|
|
}
|
|
}
|
|
|
|
func TestUploadService_WidgetUploadRequiresValidSession(t *testing.T) {
|
|
_, svc, _ := setupUploadServiceTest(t)
|
|
_, err := svc.WidgetDirectUpload(context.Background(), WidgetDirectUploadRequest{
|
|
FileHeader: createTestFileHeader(t, "visitor.png", testPNG),
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "website_token is required")
|
|
}
|
|
|
|
func TestUploadService_CompleteWidgetUploadRejectsTokenAndMIMESpoof(t *testing.T) {
|
|
db, svc, tmpDir := setupUploadServiceTest(t)
|
|
content := []byte("<script>alert(1)</script>")
|
|
upload := &model.DirectUpload{
|
|
UploadUUID: "secured-widget-upload",
|
|
AccountID: 1,
|
|
Status: model.DirectUploadStatusPending,
|
|
Source: model.DirectUploadSourceWidget,
|
|
OriginalName: "attack.png",
|
|
FileType: "image",
|
|
MimeType: "image/png",
|
|
FileSize: int64(len(content)),
|
|
FileURL: "/uploads/widget_direct/secured-widget-upload.png",
|
|
Metadata: []byte(`{"active_storage_key":"secret-token","contact_inbox_id":1}`),
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
}
|
|
require.NoError(t, db.Create(upload).Error)
|
|
|
|
_, err := svc.CompleteWidgetDirectUpload(context.Background(), upload.UploadUUID, strings.NewReader(string(content)), "wrong-token")
|
|
require.ErrorContains(t, err, "invalid direct upload token")
|
|
_, err = svc.CompleteWidgetDirectUpload(context.Background(), upload.UploadUUID, strings.NewReader(string(content)), "secret-token")
|
|
require.ErrorContains(t, err, "does not match")
|
|
_, statErr := os.Stat(filepath.Join(tmpDir, "widget_direct", "secured-widget-upload.png"))
|
|
require.ErrorIs(t, statErr, os.ErrNotExist)
|
|
}
|
|
|
|
func TestUploadService_PrivateAttachmentAccessIsTenantAndSessionScoped(t *testing.T) {
|
|
db, svc, tmpDir := setupUploadServiceTest(t)
|
|
require.NoError(t, db.AutoMigrate(&model.Conversation{}, &model.Message{}, &model.Attachment{}, &model.WidgetFileUpload{}))
|
|
var inbox model.Inbox
|
|
var contact model.Contact
|
|
var contactInbox model.ContactInbox
|
|
require.NoError(t, db.First(&inbox).Error)
|
|
require.NoError(t, db.First(&contact).Error)
|
|
require.NoError(t, db.First(&contactInbox).Error)
|
|
conversation := &model.Conversation{AccountID: 1, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
require.NoError(t, db.Create(conversation).Error)
|
|
message := &model.Message{AccountID: 1, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", Content: "file"}
|
|
require.NoError(t, db.Create(message).Error)
|
|
fileURL := "/uploads/account/1/private.png"
|
|
require.NoError(t, db.Create(&model.Attachment{AccountID: 1, MessageID: message.ID, FileType: "image", FileURL: fileURL, FileName: "private.png"}).Error)
|
|
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "account", "1"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "account", "1", "private.png"), testPNG, 0o600))
|
|
svc.WithAccessDB(db)
|
|
|
|
_, ok := svc.ResolveAuthorizedUpload(context.Background(), fileURL, 2, "")
|
|
assert.False(t, ok)
|
|
_, ok = svc.ResolveAuthorizedUpload(context.Background(), fileURL, 1, "")
|
|
assert.True(t, ok)
|
|
_, ok = svc.ResolveAuthorizedUpload(context.Background(), fileURL, 0, "test-widget")
|
|
assert.True(t, ok)
|
|
_, ok = svc.ResolveAuthorizedUpload(context.Background(), fileURL, 0, "wrong-widget")
|
|
assert.False(t, ok)
|
|
|
|
directURL := "/uploads/widget_direct/direct.png"
|
|
directMetadata, err := json.Marshal(map[string]any{"contact_inbox_id": contactInbox.ID})
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "widget_direct"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "widget_direct", "direct.png"), testPNG, 0o600))
|
|
require.NoError(t, db.Create(&model.DirectUpload{
|
|
UploadUUID: "private-direct", AccountID: 1, Status: model.DirectUploadStatusPending,
|
|
Source: model.DirectUploadSourceWidget, OriginalName: "direct.png", FileType: "image",
|
|
MimeType: "image/png", FileSize: int64(len(testPNG)), FileURL: directURL,
|
|
Metadata: directMetadata, ExpiresAt: time.Now().Add(time.Hour),
|
|
}).Error)
|
|
_, ok = svc.ResolveAuthorizedUpload(context.Background(), directURL, 0, "test-widget")
|
|
assert.True(t, ok)
|
|
_, ok = svc.ResolveAuthorizedUpload(context.Background(), directURL, 2, "")
|
|
assert.False(t, ok)
|
|
|
|
widgetURL := "/uploads/widget/1/session.png"
|
|
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "widget", "1"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "widget", "1", "session.png"), testPNG, 0o600))
|
|
require.NoError(t, db.Create(&model.WidgetFileUpload{
|
|
UploadUUID: "private-widget", WidgetToken: "test-widget", InboxID: inbox.ID, ContactID: contact.ID,
|
|
Status: model.WidgetFileUploadStatusPending, OriginalName: "session.png", FileType: "image",
|
|
MimeType: "image/png", FileSize: int64(len(testPNG)), FileURL: widgetURL, ExpiresAt: time.Now().Add(time.Hour),
|
|
}).Error)
|
|
_, ok = svc.ResolveAuthorizedUpload(context.Background(), widgetURL, 0, "test-widget")
|
|
assert.True(t, ok)
|
|
_, ok = svc.ResolveAuthorizedUpload(context.Background(), widgetURL, 0, "wrong-widget")
|
|
assert.False(t, ok)
|
|
otherInbox := &model.Inbox{AccountID: 1, Name: "Other Widget Inbox", ChannelType: "web_widget", ChannelConfig: `{"website_token":"other-website"}`, Enabled: true}
|
|
require.NoError(t, db.Create(otherInbox).Error)
|
|
require.NoError(t, db.Create(&model.ContactInbox{ContactID: contact.ID, InboxID: otherInbox.ID, PubsubToken: "other-widget"}).Error)
|
|
_, ok = svc.ResolveAuthorizedUpload(context.Background(), widgetURL, 0, "other-widget")
|
|
assert.False(t, ok)
|
|
|
|
avatarURL := "/uploads/account/1/avatar.png"
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "account", "1", "avatar.png"), testPNG, 0o600))
|
|
_, ok = svc.ResolveAuthorizedUpload(context.Background(), avatarURL, 1, "")
|
|
assert.True(t, ok)
|
|
_, ok = svc.ResolveAuthorizedUpload(context.Background(), avatarURL, 2, "")
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestUploadService_CleanupExpiredUploads(t *testing.T) {
|
|
db, svc, tmpDir := setupUploadServiceTest(t)
|
|
|
|
// Insert a pending-but-expired upload record (pending status with past expires_at)
|
|
// BatchDeleteExpired deletes pending uploads where expires_at < now
|
|
expiredUpload := &model.DirectUpload{
|
|
UploadUUID: "expired-uuid-123",
|
|
AccountID: 1,
|
|
Status: model.DirectUploadStatusPending,
|
|
Source: model.DirectUploadSourceAccount,
|
|
OriginalName: "old_file.png",
|
|
FileType: "image",
|
|
MimeType: "image/png",
|
|
FileSize: 100,
|
|
FileURL: "/uploads/account/1/old_file.png",
|
|
ExpiresAt: time.Now().Add(-1 * time.Hour), // already past expiry
|
|
}
|
|
require.NoError(t, db.Create(expiredUpload).Error)
|
|
expiredPath := filepath.Join(tmpDir, "account", "1", "old_file.png")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(expiredPath), 0o755))
|
|
require.NoError(t, os.WriteFile(expiredPath, []byte("expired"), 0o600))
|
|
|
|
// Insert a non-expired upload (pending status with future expires_at)
|
|
activeUpload := &model.DirectUpload{
|
|
UploadUUID: "active-uuid-456",
|
|
AccountID: 1,
|
|
Status: model.DirectUploadStatusPending,
|
|
Source: model.DirectUploadSourceAccount,
|
|
OriginalName: "new_file.png",
|
|
FileType: "image",
|
|
MimeType: "image/png",
|
|
FileSize: 200,
|
|
FileURL: "/uploads/account/1/new_file.png",
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
}
|
|
require.NoError(t, db.Create(activeUpload).Error)
|
|
failedUpload := &model.DirectUpload{
|
|
UploadUUID: "failed-uuid-789", AccountID: 1, Status: model.DirectUploadStatusPending,
|
|
Source: model.DirectUploadSourceAccount, OriginalName: "invalid.png", FileType: "image",
|
|
MimeType: "image/png", FileSize: 100, FileURL: "https://example.test/invalid.png",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
}
|
|
require.NoError(t, db.Create(failedUpload).Error)
|
|
|
|
count, err := svc.CleanupExpiredUploads(context.Background())
|
|
require.ErrorContains(t, err, "invalid upload path")
|
|
assert.Equal(t, int64(1), count, "should clean up 1 expired upload")
|
|
assert.NoFileExists(t, expiredPath)
|
|
|
|
// Verify active upload still exists
|
|
var remaining model.DirectUpload
|
|
require.NoError(t, db.Where("upload_uuid = ?", "active-uuid-456").First(&remaining).Error)
|
|
assert.Equal(t, model.DirectUploadStatusPending, remaining.Status)
|
|
remaining = model.DirectUpload{}
|
|
require.NoError(t, db.Where("upload_uuid = ?", "failed-uuid-789").First(&remaining).Error)
|
|
}
|
|
|
|
func TestUploadService_ReconcilesCrashWindowOrphans(t *testing.T) {
|
|
db, svc, storagePath := setupUploadServiceTest(t)
|
|
dir := filepath.Join(storagePath, "account", "1")
|
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
orphan := filepath.Join(dir, "crash-window.png")
|
|
referenced := filepath.Join(dir, "referenced.png")
|
|
fresh := filepath.Join(dir, "in-flight.png")
|
|
for _, path := range []string{orphan, referenced, fresh} {
|
|
require.NoError(t, os.WriteFile(path, testPNG, 0o600))
|
|
}
|
|
old := time.Now().Add(-uploadCleanupInterval - time.Hour)
|
|
require.NoError(t, os.Chtimes(orphan, old, old))
|
|
require.NoError(t, os.Chtimes(referenced, old, old))
|
|
require.NoError(t, db.Create(&model.DirectUpload{
|
|
UploadUUID: "referenced", AccountID: 1, Status: model.DirectUploadStatusPending,
|
|
Source: model.DirectUploadSourceAccount, OriginalName: "referenced.png", FileType: "image",
|
|
MimeType: "image/png", FileSize: int64(len(testPNG)), FileURL: "/uploads/account/1/referenced.png",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
}).Error)
|
|
|
|
count, err := svc.CleanupExpiredUploads(context.Background())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), count)
|
|
assert.NoFileExists(t, orphan)
|
|
assert.FileExists(t, referenced)
|
|
assert.FileExists(t, fresh)
|
|
|
|
count, err = svc.CleanupExpiredUploads(context.Background())
|
|
require.NoError(t, err)
|
|
assert.Zero(t, count)
|
|
}
|