feat: Integrate Google Drive support and enhance configuration handling

- Add Google Drive as a source and destination option in the configuration forms.
- Implement Google Drive authentication flow and token management.
- Update job and configuration handlers to support Google Drive-specific settings.
- Enhance UI components to include Google Drive configuration templates.
- Introduce new tests for Google Drive integration and ensure proper handling of authentication and configuration.
- Update database migrations to accommodate new fields related to Google Drive configurations.
This commit is contained in:
StarFleetCPTN committed 2025-03-15 17:36:36 -07:00
1 parent a954023800
commit 8607f2098a
52 files changed
+2795 -355

No files matched your search

+613 -15
View File
@@ -1,6 +1,7 @@
package db
import (
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -18,10 +19,10 @@ type User struct {
ID uint `gorm:"primarykey"`
Email string `gorm:"unique;not null"`
PasswordHash string `gorm:"not null"`
IsAdmin bool `gorm:"default:false"`
IsAdmin *bool `gorm:"default:false"`
LastPasswordChange time.Time
FailedLoginAttempts int `gorm:"default:0"`
AccountLocked bool `gorm:"default:false"`
FailedLoginAttempts int `gorm:"default:0"`
AccountLocked *bool `gorm:"default:false"`
LockoutUntil *time.Time
Theme string `gorm:"default:'light'"`
CreatedAt time.Time
@@ -42,7 +43,7 @@ type PasswordResetToken struct {
User User `gorm:"foreignkey:UserID"`
Token string `gorm:"not null"`
ExpiresAt time.Time `gorm:"not null"`
Used bool `gorm:"default:false"`
Used *bool `gorm:"default:false"`
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -67,7 +68,7 @@ type TransferConfig struct {
SourceShare string `form:"source_share"`
SourceDomain string `form:"source_domain"`
// FTP source fields
SourcePassiveMode bool `gorm:"default:true" form:"source_passive_mode"`
SourcePassiveMode *bool `gorm:"default:true" form:"source_passive_mode"`
// OneDrive and Google Drive source fields
SourceClientID string `form:"source_client_id"`
SourceClientSecret string `form:"source_client_secret" gorm:"-"` // Not stored in DB, only used for form
@@ -93,17 +94,19 @@ type TransferConfig struct {
DestShare string `form:"dest_share"`
DestDomain string `form:"dest_domain"`
// FTP destination fields
DestPassiveMode bool `gorm:"default:true" form:"dest_passive_mode"`
DestPassiveMode *bool `gorm:"default:true" form:"dest_passive_mode"`
// OneDrive and Google Drive destination fields
DestClientID string `form:"dest_client_id"`
DestClientSecret string `form:"dest_client_secret" gorm:"-"` // Not stored in DB, only used for form
DestDriveID string `form:"dest_drive_id"` // For OneDrive
DestTeamDrive string `form:"dest_team_drive"` // For Google Drive
// Google Drive authentication status
GoogleDriveAuthenticated *bool `gorm:"default:false"`
// General fields
ArchivePath string `form:"archive_path"`
ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"`
ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"`
RcloneFlags string `form:"rclone_flags"`
DeleteAfterTransfer bool `gorm:"default:false" form:"delete_after_transfer"`
DeleteAfterTransfer *bool `gorm:"default:false" form:"delete_after_transfer"`
SkipProcessedFiles *bool `gorm:"default:true" form:"skip_processed_files"`
MaxConcurrentTransfers int `gorm:"default:4" form:"max_concurrent_transfers"` // Number of concurrent file transfers
CreatedBy uint
@@ -119,16 +122,16 @@ type Job struct {
Config TransferConfig `gorm:"foreignkey:ConfigID"`
ConfigIDs string `gorm:"column:config_ids"` // Comma-separated list of config IDs
Schedule string `gorm:"not null" form:"schedule"`
Enabled bool `gorm:"default:true" form:"enabled"`
Enabled *bool `gorm:"default:true" form:"enabled"`
LastRun *time.Time
NextRun *time.Time
// Webhook notification fields
WebhookEnabled bool `gorm:"default:false" form:"webhook_enabled"`
WebhookEnabled *bool `gorm:"default:false" form:"webhook_enabled"`
WebhookURL string `form:"webhook_url"`
WebhookSecret string `form:"webhook_secret"`
WebhookHeaders string `form:"webhook_headers"` // JSON-encoded headers
NotifyOnSuccess bool `gorm:"default:true" form:"notify_on_success"`
NotifyOnFailure bool `gorm:"default:true" form:"notify_on_failure"`
NotifyOnSuccess *bool `gorm:"default:true" form:"notify_on_success"`
NotifyOnFailure *bool `gorm:"default:true" form:"notify_on_failure"`
CreatedBy uint
User User `gorm:"foreignkey:CreatedBy"`
CreatedAt time.Time
@@ -558,7 +561,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
"--log-level", "ERROR",
}
if config.SourcePassiveMode {
if config.SourcePassiveMode != nil && *config.SourcePassiveMode {
args = append(args, "passive", "true")
}
@@ -746,7 +749,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
"--log-level", "ERROR",
}
if config.DestPassiveMode {
if config.DestPassiveMode != nil && *config.DestPassiveMode {
args = append(args, "passive", "true")
}
@@ -799,6 +802,33 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
args = append(args, "drive_id", config.DestDriveID)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "gdrive":
args := []string{
"config", "create", destName, "drive",
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
// Only add client_id and client_secret if they're provided (not empty)
// This allows using rclone's built-in authentication
if config.DestClientID != "" && config.DestClientSecret != "" {
args = append(args, "client_id", config.DestClientID)
args = append(args, "client_secret", config.DestClientSecret)
}
if config.DestTeamDrive != "" {
args = append(args, "team_drive", config.DestTeamDrive)
}
if config.DestDriveID != "" {
args = append(args, "root_folder_id", config.DestDriveID)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
@@ -837,12 +867,14 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
return nil
}
// GetActiveJobs returns all active jobs
func (db *DB) GetActiveJobs() ([]Job, error) {
if db.DB == nil {
return nil, fmt.Errorf("database connection is nil")
}
var jobs []Job
err := db.Preload("Config").Where("enabled = ?", true).Find(&jobs).Error
// For boolean pointer fields, need to check either NULL (for default) or true value
err := db.Preload("Config").Where("enabled IS NULL OR enabled = ?", true).Find(&jobs).Error
return jobs, err
}
@@ -885,3 +917,569 @@ func (tc *TransferConfig) GetSkipProcessedFiles() bool {
func (tc *TransferConfig) SetSkipProcessedFiles(value bool) {
tc.SkipProcessedFiles = &value
}
// StoreGoogleDriveToken stores the Google Drive auth token for a config
func (db *DB) StoreGoogleDriveToken(configIDStr string, token string) error {
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid config ID: %v", err)
}
// Get the existing config
config, err := db.GetTransferConfig(uint(configID))
if err != nil {
return fmt.Errorf("failed to get config: %v", err)
}
// Mark as authenticated
authenticated := true
config.GoogleDriveAuthenticated = &authenticated
// Update the config in the database
if err := db.UpdateTransferConfig(config); err != nil {
return fmt.Errorf("failed to update config: %v", err)
}
// Get the rclone config path
configPath := db.GetConfigRclonePath(config)
// Read existing config if it exists
existingConfig := ""
if _, err := os.Stat(configPath); err == nil {
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read existing config: %v", err)
}
existingConfig = string(data)
}
// Ensure directory exists
configDir := filepath.Dir(configPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %v", err)
}
// Write the new config with the token
destName := fmt.Sprintf("dest_%d", config.ID)
newConfig := fmt.Sprintf("[%s]\ntype = drive\ntoken = %s\n", destName, token)
// If the config has client ID and secret, add them
if config.DestClientID != "" && config.DestClientSecret != "" {
newConfig += fmt.Sprintf("client_id = %s\nclient_secret = %s\n", config.DestClientID, config.DestClientSecret)
}
// Add root folder ID if specified
if config.DestDriveID != "" {
newConfig += fmt.Sprintf("root_folder_id = %s\n", config.DestDriveID)
}
// Add team drive if specified
if config.DestTeamDrive != "" {
newConfig += fmt.Sprintf("team_drive = %s\n", config.DestTeamDrive)
}
// If there's existing config, append to it; otherwise create new file
var content string
if existingConfig != "" {
// Replace/update existing dest section if it exists, otherwise append
if strings.Contains(existingConfig, fmt.Sprintf("[%s]", destName)) {
// This is a simplistic approach - in production you might want a more robust regex-based replacement
// Truncate at the beginning of the dest section
parts := strings.SplitN(existingConfig, fmt.Sprintf("[%s]", destName), 2)
// Check if there are more sections after this one
nextSectionIdx := strings.Index(parts[1], "[")
if nextSectionIdx != -1 {
content = parts[0] + newConfig + parts[1][nextSectionIdx:]
} else {
content = parts[0] + newConfig
}
} else {
content = existingConfig + "\n" + newConfig
}
} else {
content = newConfig
}
// Write the config file
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
return fmt.Errorf("failed to write config: %v", err)
}
return nil
}
// GenerateRcloneConfigWithToken generates a rclone config file for a transfer config with a provided token
func (db *DB) GenerateRcloneConfigWithToken(config *TransferConfig, token string) error {
// Ensure we have a config directory
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
configDir := filepath.Join(dataDir, "configs")
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
// Generate rclone config based on the config type
configPath := filepath.Join(configDir, fmt.Sprintf("config_%d.conf", config.ID))
// Create a new config content
var configContent strings.Builder
// First add the source configuration
sourceName := fmt.Sprintf("source_%d", config.ID)
// Handle the source configuration based on type
switch config.SourceType {
case "google_drive":
// Create a Google Drive remote using the "source_ID" naming convention for sources
sourceSection := fmt.Sprintf("[%s]\ntype = drive\n", sourceName)
// Add custom client ID and secret if provided
if config.SourceClientID != "" && config.SourceClientSecret != "" {
sourceSection += fmt.Sprintf("client_id = %s\nclient_secret = %s\n",
config.SourceClientID, config.SourceClientSecret)
}
// Add team drive if specified
if config.SourceTeamDrive != "" {
sourceSection += fmt.Sprintf("team_drive = %s\n", config.SourceTeamDrive)
}
// Clean up token string to prevent syntax errors and ensure it's a single line JSON
// First, remove any whitespace from the beginning and end
cleanToken := strings.TrimSpace(token)
// Check if it's already a JSON object
if strings.HasPrefix(cleanToken, "{") && strings.HasSuffix(cleanToken, "}") {
// It's a JSON object, but we need to make sure it's a single line
var jsonObj map[string]interface{}
if err := json.Unmarshal([]byte(cleanToken), &jsonObj); err == nil {
// Successfully parsed the JSON, now re-marshal it as a compact single line
compactJSON, err := json.Marshal(jsonObj)
if err == nil {
// Use the compact JSON as the token
sourceSection += fmt.Sprintf("token = %s\n", string(compactJSON))
} else {
// If there was an error re-marshaling, use the original but remove newlines
// Replace all newlines and carriage returns with empty string
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
sourceSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
} else {
// If we couldn't parse the JSON, just remove newlines and carriage returns
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
sourceSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
} else {
// Not a valid JSON, try to fix it
// First ensure it starts and ends with braces
if !strings.HasPrefix(cleanToken, "{") {
cleanToken = "{" + cleanToken
}
if !strings.HasSuffix(cleanToken, "}") {
cleanToken = cleanToken + "}"
}
// Remove all newlines and carriage returns
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
sourceSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
configContent.WriteString(sourceSection)
configContent.WriteString("\n")
case "sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive":
// For complex source types, use the GenerateRcloneConfig function
// to create a temporary file, read it, and then append that content
tempDir, err := os.MkdirTemp("", "gomft_temp")
if err != nil {
return fmt.Errorf("failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
tempConfigPath := filepath.Join(tempDir, "temp_config.conf")
// Create a temporary file with just the source configuration
tempContent := fmt.Sprintf("[%s]\ntype = local\n", sourceName)
if err := os.WriteFile(tempConfigPath, []byte(tempContent), 0600); err != nil {
return fmt.Errorf("failed to write temporary config: %v", err)
}
// Get rclone path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
// Use the appropriate rclone command to configure the source
var args []string
switch config.SourceType {
case "sftp":
args = []string{
"config", "create", sourceName, "sftp",
"host", config.SourceHost,
"user", config.SourceUser,
"port", fmt.Sprintf("%d", config.SourcePort),
"--non-interactive",
"--config", tempConfigPath,
"--log-level", "ERROR",
}
if config.SourcePassword != "" {
args = append(args, "pass", config.SourcePassword)
}
if config.SourceKeyFile != "" {
args = append(args, "key_file", config.SourceKeyFile)
}
case "s3":
args = []string{
"config", "create", sourceName, "s3",
"provider", "AWS",
"env_auth", "false",
"access_key_id", config.SourceAccessKey,
"secret_access_key", config.SourceSecretKey,
"region", config.SourceRegion,
"--non-interactive",
"--config", tempConfigPath,
"--log-level", "ERROR",
}
if config.SourceEndpoint != "" {
args = append(args, "endpoint", config.SourceEndpoint)
}
}
// If we have arguments, execute the command
if len(args) > 0 {
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
// Read the generated config
sourceConfig, err := os.ReadFile(tempConfigPath)
if err != nil {
return fmt.Errorf("failed to read temporary config: %v", err)
}
// Add it to our config content
configContent.WriteString(string(sourceConfig))
configContent.WriteString("\n")
}
default:
// For local source or other simple types
sourceSection := fmt.Sprintf("[%s]\ntype = local\n\n", sourceName)
configContent.WriteString(sourceSection)
}
// Now add the destination configuration
destName := fmt.Sprintf("dest_%d", config.ID)
// Set up the destination section (only supporting Google Drive for now)
if config.DestinationType == "gdrive" || config.DestinationType == "google_drive" {
// Create a Google Drive remote using "dest_ID" naming convention
destSection := fmt.Sprintf("[%s]\ntype = drive\n", destName)
// Add custom client ID and secret if provided
if config.DestClientID != "" && config.DestClientSecret != "" {
destSection += fmt.Sprintf("client_id = %s\nclient_secret = %s\n",
config.DestClientID, config.DestClientSecret)
}
// Clean up token string to prevent syntax errors and ensure it's a single line JSON
// First, remove any whitespace from the beginning and end
cleanToken := strings.TrimSpace(token)
// Check if it's already a JSON object
if strings.HasPrefix(cleanToken, "{") && strings.HasSuffix(cleanToken, "}") {
// It's a JSON object, but we need to make sure it's a single line
var jsonObj map[string]interface{}
if err := json.Unmarshal([]byte(cleanToken), &jsonObj); err == nil {
// Successfully parsed the JSON, now re-marshal it as a compact single line
compactJSON, err := json.Marshal(jsonObj)
if err == nil {
// Use the compact JSON as the token
destSection += fmt.Sprintf("token = %s\n", string(compactJSON))
} else {
// If there was an error re-marshaling, use the original but remove newlines
// Replace all newlines and carriage returns with empty string
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
destSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
} else {
// If we couldn't parse the JSON, just remove newlines and carriage returns
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
destSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
} else {
// Not a valid JSON, try to fix it
// First ensure it starts and ends with braces
if !strings.HasPrefix(cleanToken, "{") {
cleanToken = "{" + cleanToken
}
if !strings.HasSuffix(cleanToken, "}") {
cleanToken = cleanToken + "}"
}
// Remove all newlines and carriage returns
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
destSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
// Add to the config content
configContent.WriteString(destSection)
} else {
// Add a simple local destination for testing or if no specific destination type is handled
destSection := fmt.Sprintf("[%s]\ntype = local\n", destName)
configContent.WriteString(destSection)
}
// Write the config file
return os.WriteFile(configPath, []byte(configContent.String()), 0644)
}
// GetIsAdmin returns the value of IsAdmin with a default if nil
func (u *User) GetIsAdmin() bool {
if u.IsAdmin == nil {
return false // Default to false if not set
}
return *u.IsAdmin
}
// SetIsAdmin sets the IsAdmin field
func (u *User) SetIsAdmin(value bool) {
u.IsAdmin = &value
}
// GetAccountLocked returns the value of AccountLocked with a default if nil
func (u *User) GetAccountLocked() bool {
if u.AccountLocked == nil {
return false // Default to false if not set
}
return *u.AccountLocked
}
// SetAccountLocked sets the AccountLocked field
func (u *User) SetAccountLocked(value bool) {
u.AccountLocked = &value
}
// GetUsed returns the value of Used with a default if nil
func (t *PasswordResetToken) GetUsed() bool {
if t.Used == nil {
return false // Default to false if not set
}
return *t.Used
}
// SetUsed sets the Used field
func (t *PasswordResetToken) SetUsed(value bool) {
t.Used = &value
}
// GetSourcePassiveMode returns the value of SourcePassiveMode with a default if nil
func (tc *TransferConfig) GetSourcePassiveMode() bool {
if tc.SourcePassiveMode == nil {
return true // Default to true if not set
}
return *tc.SourcePassiveMode
}
// SetSourcePassiveMode sets the SourcePassiveMode field
func (tc *TransferConfig) SetSourcePassiveMode(value bool) {
tc.SourcePassiveMode = &value
}
// GetDestPassiveMode returns the value of DestPassiveMode with a default if nil
func (tc *TransferConfig) GetDestPassiveMode() bool {
if tc.DestPassiveMode == nil {
return true // Default to true if not set
}
return *tc.DestPassiveMode
}
// SetDestPassiveMode sets the DestPassiveMode field
func (tc *TransferConfig) SetDestPassiveMode(value bool) {
tc.DestPassiveMode = &value
}
// GetGoogleDriveAuthenticated returns the value of GoogleDriveAuthenticated with a default if nil
func (tc *TransferConfig) GetGoogleDriveAuthenticated() bool {
if tc.GoogleDriveAuthenticated == nil {
return false // Default to false if not set
}
return *tc.GoogleDriveAuthenticated
}
// SetGoogleDriveAuthenticated sets the GoogleDriveAuthenticated field
func (tc *TransferConfig) SetGoogleDriveAuthenticated(value bool) {
tc.GoogleDriveAuthenticated = &value
}
// GetArchiveEnabled returns the value of ArchiveEnabled with a default if nil
func (tc *TransferConfig) GetArchiveEnabled() bool {
if tc.ArchiveEnabled == nil {
return false // Default to false if not set
}
return *tc.ArchiveEnabled
}
// SetArchiveEnabled sets the ArchiveEnabled field
func (tc *TransferConfig) SetArchiveEnabled(value bool) {
tc.ArchiveEnabled = &value
}
// GetDeleteAfterTransfer returns the value of DeleteAfterTransfer with a default if nil
func (tc *TransferConfig) GetDeleteAfterTransfer() bool {
if tc.DeleteAfterTransfer == nil {
return false // Default to false if not set
}
return *tc.DeleteAfterTransfer
}
// SetDeleteAfterTransfer sets the DeleteAfterTransfer field
func (tc *TransferConfig) SetDeleteAfterTransfer(value bool) {
tc.DeleteAfterTransfer = &value
}
// GetEnabled returns the value of Enabled with a default if nil
func (j *Job) GetEnabled() bool {
if j.Enabled == nil {
return true // Default to true if not set
}
return *j.Enabled
}
// SetEnabled sets the Enabled field
func (j *Job) SetEnabled(value bool) {
j.Enabled = &value
}
// GetWebhookEnabled returns the value of WebhookEnabled with a default if nil
func (j *Job) GetWebhookEnabled() bool {
if j.WebhookEnabled == nil {
return false // Default to false if not set
}
return *j.WebhookEnabled
}
// SetWebhookEnabled sets the WebhookEnabled field
func (j *Job) SetWebhookEnabled(value bool) {
j.WebhookEnabled = &value
}
// GetNotifyOnSuccess returns the value of NotifyOnSuccess with a default if nil
func (j *Job) GetNotifyOnSuccess() bool {
if j.NotifyOnSuccess == nil {
return true // Default to true if not set
}
return *j.NotifyOnSuccess
}
// SetNotifyOnSuccess sets the NotifyOnSuccess field
func (j *Job) SetNotifyOnSuccess(value bool) {
j.NotifyOnSuccess = &value
}
// GetNotifyOnFailure returns the value of NotifyOnFailure with a default if nil
func (j *Job) GetNotifyOnFailure() bool {
if j.NotifyOnFailure == nil {
return true // Default to true if not set
}
return *j.NotifyOnFailure
}
// SetNotifyOnFailure sets the NotifyOnFailure field
func (j *Job) SetNotifyOnFailure(value bool) {
j.NotifyOnFailure = &value
}
// GetGDriveCredentialsFromConfig extracts Google Drive client ID and secret from an existing rclone config file
func (db *DB) GetGDriveCredentialsFromConfig(config *TransferConfig) (string, string) {
configPath := db.GetConfigRclonePath(config)
if configPath == "" {
return "", ""
}
// Check if the file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return "", ""
}
// Read the rclone config file
content, err := os.ReadFile(configPath)
if err != nil {
return "", ""
}
// Parse the content to extract client_id and client_secret from both source and destination sections
lines := strings.Split(string(content), "\n")
// Define section names based on config ID
sourceSectionName := fmt.Sprintf("[source_%d]", config.ID)
destSectionName := fmt.Sprintf("[dest_%d]", config.ID)
var inSourceSection, inDestSection bool
var sourceClientID, sourceClientSecret, destClientID, destClientSecret string
for _, line := range lines {
line = strings.TrimSpace(line)
// Check if we're entering a new section
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
inSourceSection = line == sourceSectionName
inDestSection = line == destSectionName
continue
}
// Extract credentials from source section
if inSourceSection {
if strings.HasPrefix(line, "client_id") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
sourceClientID = strings.TrimSpace(parts[1])
}
} else if strings.HasPrefix(line, "client_secret") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
sourceClientSecret = strings.TrimSpace(parts[1])
}
}
}
// Extract credentials from destination section
if inDestSection {
if strings.HasPrefix(line, "client_id") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
destClientID = strings.TrimSpace(parts[1])
}
} else if strings.HasPrefix(line, "client_secret") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
destClientSecret = strings.TrimSpace(parts[1])
}
}
}
// If we found both values in both sections, we can stop processing
if sourceClientID != "" && sourceClientSecret != "" && destClientID != "" && destClientSecret != "" {
break
}
}
// Prefer destination credentials since we're authenticating for destination
if destClientID != "" && destClientSecret != "" {
return destClientID, destClientSecret
}
// Fall back to source credentials if available
if sourceClientID != "" && sourceClientSecret != "" {
return sourceClientID, sourceClientSecret
}
return "", ""
}
+596 -13
View File
@@ -3,6 +3,7 @@ package db
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
@@ -42,9 +43,9 @@ func TestUserCRUD(t *testing.T) {
testUser := &User{
Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
IsAdmin: true,
LastPasswordChange: time.Now(),
}
testUser.SetIsAdmin(true)
// Test Create
err := db.CreateUser(testUser)
@@ -113,7 +114,7 @@ func TestPasswordResetToken(t *testing.T) {
}
assert.Equal(t, testToken.ID, retrievedToken.ID, "Retrieved token should have the same ID")
assert.Equal(t, testUser.ID, retrievedToken.UserID, "Retrieved token should reference the correct user")
assert.False(t, retrievedToken.Used, "Token should not be marked as used initially")
assert.False(t, retrievedToken.GetUsed(), "Token should not be marked as used initially")
// Mark token as used
err = db.MarkPasswordResetTokenAsUsed(retrievedToken.ID)
@@ -129,7 +130,7 @@ func TestPasswordResetToken(t *testing.T) {
if result.Error != nil {
t.Fatalf("Failed to get updated password reset token: %v", result.Error)
}
assert.True(t, updatedToken.Used, "Token should be marked as used")
assert.True(t, updatedToken.GetUsed(), "Token should be marked as used")
}
func TestTransferConfigCRUD(t *testing.T) {
@@ -240,11 +241,11 @@ func TestJobCRUD(t *testing.T) {
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run every hour
Enabled: true,
LastRun: &now,
NextRun: &nextRun,
CreatedBy: testUser.ID,
}
testJob.SetEnabled(true)
// Test Create
err = db.CreateJob(testJob)
@@ -278,7 +279,7 @@ func TestJobCRUD(t *testing.T) {
// Test Update
retrievedJob.Name = fmt.Sprintf("Updated Job %d", time.Now().UnixNano())
retrievedJob.Enabled = false
retrievedJob.SetEnabled(false)
err = db.UpdateJob(retrievedJob)
if err != nil {
t.Fatalf("Failed to update job: %v", err)
@@ -367,7 +368,6 @@ func TestJobMultipleConfigs(t *testing.T) {
testJob := &Job{
Name: "Multi Config Job",
Schedule: "0 * * * *",
Enabled: true,
CreatedBy: testUser.ID,
}
@@ -468,7 +468,6 @@ func TestJobHistoryCRUD(t *testing.T) {
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run every hour
Enabled: true,
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob)
@@ -548,7 +547,6 @@ func TestFileMetadataCRUD(t *testing.T) {
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run every hour
Enabled: true,
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob)
@@ -634,7 +632,7 @@ func TestGetConfigRclonePath(t *testing.T) {
testUser := &User{
Email: "rclone-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
@@ -671,7 +669,7 @@ func TestGenerateRcloneConfig(t *testing.T) {
testUser := &User{
Email: "rclone-gen-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
@@ -708,7 +706,7 @@ func TestGenerateRcloneConfig(t *testing.T) {
DestHost: "ftp.example.com",
DestPort: 21,
DestUser: "ftpuser",
DestPassiveMode: true,
DestPassiveMode: BoolPtr(true),
CreatedBy: testUser.ID,
}
@@ -770,7 +768,7 @@ func TestUpdateJobStatus(t *testing.T) {
testUser := &User{
Email: "job-status-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
@@ -800,11 +798,11 @@ func TestUpdateJobStatus(t *testing.T) {
Name: "Test Job Status",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
Enabled: true,
LastRun: &lastRun,
NextRun: &nextRun,
CreatedBy: testUser.ID,
}
testJob.SetEnabled(true)
err = db.CreateJob(testJob)
assert.NoError(t, err)
@@ -833,3 +831,588 @@ func TestUpdateJobStatus(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, updatedNextRun.Unix(), updatedJob.NextRun.Unix())
}
func BoolPtr(b bool) *bool {
return &b
}
func TestGoogleDriveTransferConfig(t *testing.T) {
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err := db.CreateUser(testUser)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Test 1: Create config with Google Drive as source
googleSourceConfig := &TransferConfig{
Name: "Google Drive Source Test",
SourceType: "google_drive",
SourcePath: "/path/in/google/drive",
SourceClientID: "google_client_id",
SourceClientSecret: "google_client_secret",
SourceTeamDrive: "team_drive_id",
DestinationType: "local",
DestinationPath: "/local/destination/path",
FilePattern: "*.pdf",
CreatedBy: testUser.ID,
}
// Set authenticated status
authenticated := true
googleSourceConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleSourceConfig)
assert.NoError(t, err)
assert.NotZero(t, googleSourceConfig.ID, "Config ID should be set after creation")
// Test 2: Create config with Google Drive as destination
googleDestConfig := &TransferConfig{
Name: "Google Drive Destination Test",
SourceType: "local",
SourcePath: "/local/source/path",
DestinationType: "google_drive",
DestinationPath: "/path/in/google/drive",
DestClientID: "google_client_id",
DestClientSecret: "google_client_secret",
DestTeamDrive: "team_drive_id",
FilePattern: "*.docx",
CreatedBy: testUser.ID,
}
// Set authenticated status
googleDestConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleDestConfig)
assert.NoError(t, err)
assert.NotZero(t, googleDestConfig.ID, "Config ID should be set after creation")
// Test 3: Create config with Google Drive as both source and destination
googleBothConfig := &TransferConfig{
Name: "Google Drive Both Test",
SourceType: "google_drive",
SourcePath: "/source/path/in/google/drive",
SourceClientID: "source_google_client_id",
SourceClientSecret: "source_google_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "google_drive",
DestinationPath: "/dest/path/in/google/drive",
DestClientID: "dest_google_client_id",
DestClientSecret: "dest_google_client_secret",
DestTeamDrive: "dest_team_drive_id",
FilePattern: "*.xlsx",
CreatedBy: testUser.ID,
}
// Set authenticated status
googleBothConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleBothConfig)
assert.NoError(t, err)
assert.NotZero(t, googleBothConfig.ID, "Config ID should be set after creation")
// Test retrieving and verifying Google Drive configs
retrievedSourceConfig, err := db.GetTransferConfig(googleSourceConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "google_drive", retrievedSourceConfig.SourceType)
assert.Equal(t, "/path/in/google/drive", retrievedSourceConfig.SourcePath)
assert.Equal(t, "google_client_id", retrievedSourceConfig.SourceClientID)
assert.Equal(t, "team_drive_id", retrievedSourceConfig.SourceTeamDrive)
assert.True(t, *retrievedSourceConfig.GoogleDriveAuthenticated)
retrievedDestConfig, err := db.GetTransferConfig(googleDestConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "google_drive", retrievedDestConfig.DestinationType)
assert.Equal(t, "/path/in/google/drive", retrievedDestConfig.DestinationPath)
assert.Equal(t, "google_client_id", retrievedDestConfig.DestClientID)
assert.Equal(t, "team_drive_id", retrievedDestConfig.DestTeamDrive)
assert.True(t, *retrievedDestConfig.GoogleDriveAuthenticated)
// Test updating Google Drive config
retrievedSourceConfig.SourcePath = "/updated/google/drive/path"
retrievedSourceConfig.SourceTeamDrive = "updated_team_drive_id"
err = db.UpdateTransferConfig(retrievedSourceConfig)
assert.NoError(t, err)
// Verify update
updatedConfig, err := db.GetTransferConfig(googleSourceConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "/updated/google/drive/path", updatedConfig.SourcePath)
assert.Equal(t, "updated_team_drive_id", updatedConfig.SourceTeamDrive)
// Test changing authentication status
unauthenticated := false
updatedConfig.GoogleDriveAuthenticated = &unauthenticated
err = db.UpdateTransferConfig(updatedConfig)
assert.NoError(t, err)
// Verify authentication status update
finalConfig, err := db.GetTransferConfig(googleSourceConfig.ID)
assert.NoError(t, err)
assert.False(t, *finalConfig.GoogleDriveAuthenticated)
// Clean up
err = db.DeleteTransferConfig(googleSourceConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(googleDestConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(googleBothConfig.ID)
assert.NoError(t, err)
}
func TestGoogleDriveJobExecution(t *testing.T) {
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-job-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err := db.CreateUser(testUser)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Create a test transfer config with Google Drive as source
googleConfig := &TransferConfig{
Name: "Google Drive Job Test",
SourceType: "google_drive",
SourcePath: "/source/path/in/google/drive",
SourceClientID: "google_client_id",
SourceClientSecret: "google_client_secret",
SourceTeamDrive: "team_drive_id",
DestinationType: "local",
DestinationPath: "/local/destination/path",
FilePattern: "*.pdf",
CreatedBy: testUser.ID,
}
// Set authenticated status
authenticated := true
googleConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleConfig)
assert.NoError(t, err)
assert.NotZero(t, googleConfig.ID, "Config ID should be set after creation")
// Create a job using the Google Drive config
job := &Job{
Name: "Google Drive Test Job",
Schedule: "0 * * * *", // Run every hour
ConfigID: googleConfig.ID,
CreatedBy: testUser.ID,
}
// Set job as enabled
job.SetEnabled(true)
// Set up webhook notifications
job.SetWebhookEnabled(true)
job.WebhookURL = "https://example.com/webhook"
job.SetNotifyOnSuccess(true)
job.SetNotifyOnFailure(true)
// Create the job
err = db.CreateJob(job)
assert.NoError(t, err)
assert.NotZero(t, job.ID, "Job ID should be set after creation")
// Test retrieving the job
retrievedJob, err := db.GetJob(job.ID)
assert.NoError(t, err)
assert.Equal(t, "Google Drive Test Job", retrievedJob.Name)
assert.Equal(t, googleConfig.ID, retrievedJob.ConfigID)
assert.True(t, retrievedJob.GetEnabled())
assert.True(t, retrievedJob.GetWebhookEnabled())
assert.Equal(t, "https://example.com/webhook", retrievedJob.WebhookURL)
assert.True(t, retrievedJob.GetNotifyOnSuccess())
assert.True(t, retrievedJob.GetNotifyOnFailure())
// Create job history entry for this job
startTime := time.Now().Add(-10 * time.Minute)
endTime := time.Now()
jobHistory := &JobHistory{
JobID: job.ID,
ConfigID: googleConfig.ID,
StartTime: startTime,
EndTime: &endTime,
Status: "success",
BytesTransferred: 1024 * 1024 * 5, // 5 MB
FilesTransferred: 3,
}
err = db.Create(jobHistory).Error
assert.NoError(t, err)
assert.NotZero(t, jobHistory.ID, "JobHistory ID should be set after creation")
// Create file metadata entries
fileMetadata1 := &FileMetadata{
JobID: job.ID,
ConfigID: googleConfig.ID,
FileName: "document1.pdf",
OriginalPath: "/source/path/in/google/drive/document1.pdf",
FileSize: 1024 * 1024 * 2, // 2 MB
FileHash: "hash1",
CreationTime: time.Now().Add(-24 * time.Hour),
ModTime: time.Now().Add(-12 * time.Hour),
ProcessedTime: startTime.Add(1 * time.Minute),
DestinationPath: "/local/destination/path/document1.pdf",
Status: "processed",
}
fileMetadata2 := &FileMetadata{
JobID: job.ID,
ConfigID: googleConfig.ID,
FileName: "document2.pdf",
OriginalPath: "/source/path/in/google/drive/document2.pdf",
FileSize: 1024 * 1024 * 1, // 1 MB
FileHash: "hash2",
CreationTime: time.Now().Add(-24 * time.Hour),
ModTime: time.Now().Add(-12 * time.Hour),
ProcessedTime: startTime.Add(2 * time.Minute),
DestinationPath: "/local/destination/path/document2.pdf",
Status: "processed",
}
fileMetadata3 := &FileMetadata{
JobID: job.ID,
ConfigID: googleConfig.ID,
FileName: "document3.pdf",
OriginalPath: "/source/path/in/google/drive/document3.pdf",
FileSize: 1024 * 1024 * 2, // 2 MB
FileHash: "hash3",
CreationTime: time.Now().Add(-24 * time.Hour),
ModTime: time.Now().Add(-12 * time.Hour),
ProcessedTime: startTime.Add(3 * time.Minute),
DestinationPath: "/local/destination/path/document3.pdf",
Status: "processed",
}
err = db.Create(fileMetadata1).Error
assert.NoError(t, err)
err = db.Create(fileMetadata2).Error
assert.NoError(t, err)
err = db.Create(fileMetadata3).Error
assert.NoError(t, err)
// Test fetching job history
var histories []JobHistory
err = db.Where("job_id = ?", job.ID).Find(&histories).Error
assert.NoError(t, err)
assert.Equal(t, 1, len(histories), "Should have 1 job history entry")
assert.Equal(t, job.ID, histories[0].JobID)
assert.Equal(t, googleConfig.ID, histories[0].ConfigID)
assert.Equal(t, "success", histories[0].Status)
assert.Equal(t, int64(1024*1024*5), histories[0].BytesTransferred)
assert.Equal(t, 3, histories[0].FilesTransferred)
// Test fetching file metadata
var files []FileMetadata
err = db.Where("job_id = ?", job.ID).Find(&files).Error
assert.NoError(t, err)
assert.Equal(t, 3, len(files), "Should have 3 file metadata entries")
// Clean up
err = db.Where("job_id = ?", job.ID).Delete(&FileMetadata{}).Error
assert.NoError(t, err)
err = db.Where("job_id = ?", job.ID).Delete(&JobHistory{}).Error
assert.NoError(t, err)
err = db.Delete(&job).Error
assert.NoError(t, err)
err = db.Delete(&googleConfig).Error
assert.NoError(t, err)
}
func TestGoogleDriveAuthentication(t *testing.T) {
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-auth-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err := db.CreateUser(testUser)
assert.NoError(t, err)
// Create a Google Drive config that requires authentication
googleConfig := &TransferConfig{
Name: "Google Drive Auth Test",
SourceType: "google_drive",
SourcePath: "/source/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
DestinationType: "local",
DestinationPath: "/local/path",
CreatedBy: testUser.ID,
}
// Initially not authenticated
unauthenticated := false
googleConfig.GoogleDriveAuthenticated = &unauthenticated
// Create the config
err = db.CreateTransferConfig(googleConfig)
assert.NoError(t, err)
// Test 1: Verify initial unauthenticated state
retrievedConfig, err := db.GetTransferConfig(googleConfig.ID)
assert.NoError(t, err)
assert.False(t, retrievedConfig.GetGoogleDriveAuthenticated())
// Test 2: Simulate authentication with token
mockToken := `{"access_token":"test_access_token","refresh_token":"test_refresh_token","expiry":"2023-12-31T12:00:00Z"}`
err = db.StoreGoogleDriveToken(fmt.Sprintf("%d", googleConfig.ID), mockToken)
assert.NoError(t, err)
// Verify authentication state was updated
updatedConfig, err := db.GetTransferConfig(googleConfig.ID)
assert.NoError(t, err)
assert.True(t, updatedConfig.GetGoogleDriveAuthenticated())
// Test 3: Generate rclone config with token
err = db.GenerateRcloneConfigWithToken(updatedConfig, mockToken)
assert.NoError(t, err)
// Get config path
configPath := db.GetConfigRclonePath(updatedConfig)
// On test systems, the directory might not exist
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
// Create directory if it doesn't exist
err = os.MkdirAll(configDir, 0755)
assert.NoError(t, err)
}
// Check if config was generated properly
_, err = os.Stat(configPath)
// In a test environment, this may fail if the rclone executable is not available
// or permissions are wrong, so we'll just log it rather than fail the test
if err != nil {
t.Logf("Warning: could not verify rclone config file: %v", err)
}
// Test 4: Simulate deauthentication (token revocation)
updatedConfig.SetGoogleDriveAuthenticated(false)
err = db.UpdateTransferConfig(updatedConfig)
assert.NoError(t, err)
// Verify deauthentication
finalConfig, err := db.GetTransferConfig(googleConfig.ID)
assert.NoError(t, err)
assert.False(t, finalConfig.GetGoogleDriveAuthenticated())
// Clean up
err = db.DeleteTransferConfig(googleConfig.ID)
assert.NoError(t, err)
}
func TestGoogleDriveErrorHandling(t *testing.T) {
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-error-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err := db.CreateUser(testUser)
assert.NoError(t, err)
// Test 1: Create a config with missing required fields
incompleteConfig := &TransferConfig{
Name: "Incomplete Google Drive Config",
SourceType: "google_drive",
SourcePath: "", // Missing path
DestinationType: "local",
DestinationPath: "/local/path",
CreatedBy: testUser.ID,
}
// This should still succeed at the database level, as validation typically happens at the application level
err = db.CreateTransferConfig(incompleteConfig)
assert.NoError(t, err)
assert.NotZero(t, incompleteConfig.ID, "Config ID should be set after creation")
// Test 2: Config with invalid Team Drive ID
invalidTeamDriveConfig := &TransferConfig{
Name: "Invalid Team Drive Config",
SourceType: "google_drive",
SourcePath: "/test/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
SourceTeamDrive: "invalid_team_drive_id",
DestinationType: "local",
DestinationPath: "/local/path",
CreatedBy: testUser.ID,
}
err = db.CreateTransferConfig(invalidTeamDriveConfig)
assert.NoError(t, err)
// Set it as authenticated (this would normally fail in a real environment)
authenticated := true
invalidTeamDriveConfig.GoogleDriveAuthenticated = &authenticated
err = db.UpdateTransferConfig(invalidTeamDriveConfig)
assert.NoError(t, err)
// When trying to test a transfer with an invalid team drive in a real environment,
// the rclone command would fail. We can't directly test this in a unit test,
// but we can verify the config is properly set up to cause the expected failure.
retrievedConfig, err := db.GetTransferConfig(invalidTeamDriveConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "invalid_team_drive_id", retrievedConfig.SourceTeamDrive, "Retrieved config should have the invalid team drive ID")
assert.True(t, retrievedConfig.GetGoogleDriveAuthenticated(), "Config should be marked as authenticated")
// Test 3: Test authentication error scenario - using malformed token
badTokenConfig := &TransferConfig{
Name: "Bad Token Config",
SourceType: "google_drive",
SourcePath: "/test/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
DestinationType: "local",
DestinationPath: "/local/path",
CreatedBy: testUser.ID,
}
err = db.CreateTransferConfig(badTokenConfig)
assert.NoError(t, err)
// Try to store a malformed token - shouldn't crash but may fail
// In real-world usage, this would lead to auth failures when trying to use the token
malformedToken := `{"not_valid_json`
err = db.StoreGoogleDriveToken(fmt.Sprintf("%d", badTokenConfig.ID), malformedToken)
// Even with malformed tokens, the DB operation might succeed as we're just storing a string
// but the authentication would fail in actual usage
if err != nil {
t.Logf("StoreGoogleDriveToken returned error with malformed token as expected: %v", err)
}
// Clean up
err = db.DeleteTransferConfig(incompleteConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(invalidTeamDriveConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(badTokenConfig.ID)
assert.NoError(t, err)
}
func TestGoogleDriveTeamDrive(t *testing.T) {
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-teamdrive-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err := db.CreateUser(testUser)
assert.NoError(t, err)
// Test 1: Configure source with Team Drive
teamDriveSourceConfig := &TransferConfig{
Name: "Team Drive Source Test",
SourceType: "google_drive",
SourcePath: "/shared/documents",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "local",
DestinationPath: "/local/path",
FilePattern: "*.pdf",
CreatedBy: testUser.ID,
}
// Set as authenticated
authenticated := true
teamDriveSourceConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(teamDriveSourceConfig)
assert.NoError(t, err)
assert.NotZero(t, teamDriveSourceConfig.ID, "Config ID should be set after creation")
// Test 2: Configure destination with Team Drive
teamDriveDestConfig := &TransferConfig{
Name: "Team Drive Destination Test",
SourceType: "local",
SourcePath: "/local/source",
DestinationType: "google_drive",
DestinationPath: "/team/drive/path",
DestClientID: "test_client_id",
DestClientSecret: "test_client_secret",
DestTeamDrive: "dest_team_drive_id",
FilePattern: "*.docx",
CreatedBy: testUser.ID,
}
// Set as authenticated
teamDriveDestConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(teamDriveDestConfig)
assert.NoError(t, err)
assert.NotZero(t, teamDriveDestConfig.ID, "Config ID should be set after creation")
// Test 3: Configure both source and destination with Team Drive
teamDriveBothConfig := &TransferConfig{
Name: "Team Drive Both Test",
SourceType: "google_drive",
SourcePath: "/source/team/drive/path",
SourceClientID: "source_client_id",
SourceClientSecret: "source_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "google_drive",
DestinationPath: "/dest/team/drive/path",
DestClientID: "dest_client_id",
DestClientSecret: "dest_client_secret",
DestTeamDrive: "dest_team_drive_id",
FilePattern: "*.xlsx",
CreatedBy: testUser.ID,
}
// Set as authenticated
teamDriveBothConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(teamDriveBothConfig)
assert.NoError(t, err)
assert.NotZero(t, teamDriveBothConfig.ID, "Config ID should be set after creation")
// Test retrieving configs and verify Team Drive IDs are set correctly
retrievedSourceConfig, err := db.GetTransferConfig(teamDriveSourceConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "source_team_drive_id", retrievedSourceConfig.SourceTeamDrive)
assert.Empty(t, retrievedSourceConfig.DestTeamDrive)
retrievedDestConfig, err := db.GetTransferConfig(teamDriveDestConfig.ID)
assert.NoError(t, err)
assert.Empty(t, retrievedDestConfig.SourceTeamDrive)
assert.Equal(t, "dest_team_drive_id", retrievedDestConfig.DestTeamDrive)
retrievedBothConfig, err := db.GetTransferConfig(teamDriveBothConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "source_team_drive_id", retrievedBothConfig.SourceTeamDrive)
assert.Equal(t, "dest_team_drive_id", retrievedBothConfig.DestTeamDrive)
// Clean up
err = db.DeleteTransferConfig(teamDriveSourceConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(teamDriveDestConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(teamDriveBothConfig.ID)
assert.NoError(t, err)
}
+3 -3
View File
@@ -66,7 +66,7 @@ func TestDeleteTransferConfigEdgeCases(t *testing.T) {
Name: "Job for Config",
ConfigID: configWithJob.ID,
Schedule: "0 * * * *",
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(job)
@@ -122,7 +122,7 @@ func TestDeleteJobEdgeCases(t *testing.T) {
Name: fmt.Sprintf("Edge Job %d", i),
ConfigID: config.ID,
Schedule: "0 * * * *",
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(job)
@@ -145,7 +145,7 @@ func TestDeleteJobEdgeCases(t *testing.T) {
Name: "Job with History",
ConfigID: config.ID,
Schedule: "0 * * * *",
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(jobWithHistory)
+2 -2
View File
@@ -70,7 +70,7 @@ func TestGetPasswordResetTokenError(t *testing.T) {
UserID: testUser.ID,
Token: "used-token",
ExpiresAt: time.Now().Add(1 * time.Hour),
Used: true,
Used: BoolPtr(true),
}
err = db.CreatePasswordResetToken(usedToken)
assert.NoError(t, err)
@@ -131,7 +131,7 @@ func TestGenerateRcloneConfigErrors(t *testing.T) {
testUser := &User{
Email: "config-error-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
+1 -1
View File
@@ -66,7 +66,7 @@ func TestInitializeWithExistingDB(t *testing.T) {
user := &User{
Email: "test@example.com",
PasswordHash: "hash",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
err = db1.CreateUser(user)
assert.NoError(t, err)
@@ -0,0 +1,21 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddGoogleDriveAuthenticated adds the GoogleDriveAuthenticated field to the transfer_configs table
func AddGoogleDriveAuthenticated() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20240315_add_google_drive_authenticated",
Migrate: func(tx *gorm.DB) error {
// Add the GoogleDriveAuthenticated column with a default value of false
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN google_drive_authenticated BOOLEAN DEFAULT false").Error
},
Rollback: func(tx *gorm.DB) error {
// Remove the GoogleDriveAuthenticated column
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN google_drive_authenticated").Error
},
}
}
+1
View File
@@ -16,6 +16,7 @@ func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
AddMultiConfigSupport(),
UpdateSkipProcessedFilesToNullable(),
AddWebhookSupport(),
AddGoogleDriveAuthenticated(),
}
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
+114
View File
@@ -3,6 +3,7 @@ package db
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
@@ -135,3 +136,116 @@ func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
}
}
}
func TestGoogleDriveRcloneConfig(t *testing.T) {
// Skip if rclone not available
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone" // default to PATH lookup
}
_, err := exec.Command(rclonePath, "--version").CombinedOutput()
if err != nil {
t.Skip("Skipping test as rclone is not available")
}
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-rclone-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err = db.CreateUser(testUser)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Create Google Drive source config
googleSourceConfig := &TransferConfig{
Name: "Google Drive Source Rclone Test",
SourceType: "google_drive",
SourcePath: "/path/in/google/drive",
SourceClientID: "source_google_client_id",
SourceClientSecret: "source_google_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "local",
DestinationPath: "/local/destination/path",
FilePattern: "*.pdf",
CreatedBy: testUser.ID,
}
// Set authenticated status
authenticated := true
googleSourceConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleSourceConfig)
assert.NoError(t, err)
err = db.GenerateRcloneConfigWithToken(googleSourceConfig, "test_token")
assert.NoError(t, err)
// Generate rclone config for source
configPath := db.GetConfigRclonePath(googleSourceConfig)
// Check that the file exists
_, err = os.Stat(configPath)
assert.NoError(t, err, "Rclone config file should exist")
// Read the config file
configContent, err := os.ReadFile(configPath)
assert.NoError(t, err)
content := string(configContent)
// Verify it contains Google Drive specific content
assert.Contains(t, content, "type = drive")
assert.Contains(t, content, fmt.Sprintf("client_id = %s", googleSourceConfig.SourceClientID))
assert.Contains(t, content, "source")
assert.Contains(t, content, fmt.Sprintf("team_drive = %s", googleSourceConfig.SourceTeamDrive))
// Create Google Drive destination config
googleDestConfig := &TransferConfig{
Name: "Google Drive Dest Rclone Test",
SourceType: "local",
SourcePath: "/local/source/path",
DestinationType: "google_drive",
DestinationPath: "/dest/path/in/google/drive",
DestClientID: "dest_google_client_id",
DestClientSecret: "dest_google_client_secret",
DestTeamDrive: "dest_team_drive_id",
FilePattern: "*.pdf",
CreatedBy: testUser.ID,
}
// Set authenticated status
googleDestConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleDestConfig)
assert.NoError(t, err)
// Generate rclone config for destination
configPath = db.GetConfigRclonePath(googleDestConfig)
// Check that the file exists
_, err = os.Stat(configPath)
assert.NoError(t, err, "Rclone config file should exist")
// Read the config file
configContent, err = os.ReadFile(configPath)
assert.NoError(t, err)
content = string(configContent)
// Verify it contains Google Drive specific content
assert.Contains(t, content, "type = drive")
assert.Contains(t, content, fmt.Sprintf("client_id = %s", googleDestConfig.DestClientID))
assert.Contains(t, content, "dest")
assert.Contains(t, content, fmt.Sprintf("team_drive = %s", googleDestConfig.DestTeamDrive))
// Clean up
err = db.Delete(&googleSourceConfig).Error
assert.NoError(t, err)
err = db.Delete(&googleDestConfig).Error
assert.NoError(t, err)
}
+2 -2
View File
@@ -117,7 +117,7 @@ func TestDeleteJobWithTransaction(t *testing.T) {
Name: "Test Delete Job",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob)
@@ -137,7 +137,7 @@ func TestDeleteJobWithTransaction(t *testing.T) {
Name: "Test Delete Job 2",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob2)