mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49753c4fc0 | ||
|
|
c6aed6b36d | ||
|
|
3060b4266a | ||
|
|
ebeb597f17 | ||
|
|
4783784325 | ||
|
|
bd41433bdb | ||
|
|
a9073787d2 | ||
|
|
0890bf8f09 | ||
|
|
f8c11e8802 | ||
|
|
e798d82fc1 | ||
|
|
81a01585ee | ||
|
|
a8465c1a10 | ||
|
|
a9e5db70f6 | ||
|
|
7a47be6ca6 | ||
|
|
16be3db0c6 | ||
|
|
744e51d1e1 | ||
|
|
b3af75d430 | ||
|
|
6f7320abeb | ||
|
|
a1655d35a6 | ||
|
|
9b6e801184 | ||
|
|
105777ab6f | ||
|
|
3a1a88d5cf | ||
|
|
699ca16814 | ||
|
|
26f3cf233a | ||
|
|
3d8372e9f6 | ||
|
|
b46f11804d | ||
|
|
4676361688 | ||
|
|
de3679cadf | ||
|
|
8f03a30af2 | ||
|
|
356529c58a | ||
|
|
e7eed056f7 | ||
|
|
6084cdc954 | ||
|
|
c50bcc57b1 | ||
|
|
ea76300ed7 | ||
|
|
9b413e4076 | ||
|
|
f91cb260f2 | ||
|
|
8f37a8082f | ||
|
|
5cf7614772 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
ansible/
|
||||
postgresus_data/
|
||||
postgresus-data/
|
||||
databasus-data/
|
||||
@@ -9,4 +10,5 @@ node_modules/
|
||||
/articles
|
||||
|
||||
.DS_Store
|
||||
/scripts
|
||||
/scripts
|
||||
.vscode/settings.json
|
||||
|
||||
51
AGENTS.md
51
AGENTS.md
@@ -573,15 +573,15 @@ var (
|
||||
|
||||
func SetupDependencies() {
|
||||
wasAlreadySetup := isSetup.Load()
|
||||
|
||||
|
||||
setupOnce.Do(func() {
|
||||
// Initialize dependencies here
|
||||
someService.SetDependency(otherService)
|
||||
anotherService.AddListener(listener)
|
||||
|
||||
|
||||
isSetup.Store(true)
|
||||
})
|
||||
|
||||
|
||||
if wasAlreadySetup {
|
||||
logger.GetLogger().Warn("SetupDependencies called multiple times, ignoring subsequent call")
|
||||
}
|
||||
@@ -630,14 +630,14 @@ type BackgroundService struct {
|
||||
|
||||
func (s *BackgroundService) Run(ctx context.Context) {
|
||||
wasAlreadyRun := s.hasRun.Load()
|
||||
|
||||
|
||||
s.runOnce.Do(func() {
|
||||
s.hasRun.Store(true)
|
||||
|
||||
|
||||
// Existing infinite loop logic
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -647,7 +647,7 @@ func (s *BackgroundService) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if wasAlreadyRun {
|
||||
panic(fmt.Sprintf("%T.Run() called multiple times", s))
|
||||
}
|
||||
@@ -766,6 +766,43 @@ Use these naming patterns:
|
||||
- Consolidate similar test patterns across different test files
|
||||
- Make tests more readable and maintainable for other developers
|
||||
|
||||
**Clean Up Test Data:**
|
||||
|
||||
- If the feature supports cleanup operations (DELETE endpoints, cleanup methods), use them in tests
|
||||
- Clean up resources after test execution to avoid test data pollution
|
||||
- Use `defer` statements or explicit cleanup calls at the end of tests
|
||||
- Prioritize using API methods for cleanup (not direct database deletion)
|
||||
- Examples:
|
||||
- CRUD features: delete created records via DELETE endpoint
|
||||
- File uploads: remove uploaded files
|
||||
- Background jobs: stop schedulers or cancel running tasks
|
||||
- Skip cleanup only when:
|
||||
- Tests run in isolated transactions that auto-rollback
|
||||
- Cleanup endpoint doesn't exist yet
|
||||
- Test explicitly validates failure scenarios where cleanup isn't possible
|
||||
|
||||
**Example:**
|
||||
|
||||
```go
|
||||
func Test_BackupLifecycle_CreateAndDelete(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test", owner)
|
||||
|
||||
// Create backup config
|
||||
config := createBackupConfig(t, router, workspace.ID, owner.Token)
|
||||
|
||||
// Cleanup at end of test
|
||||
defer deleteBackupConfig(t, router, workspace.ID, config.ID, owner.Token)
|
||||
|
||||
// Test operations...
|
||||
triggerBackup(t, router, workspace.ID, config.ID, owner.Token)
|
||||
|
||||
// Verify backup was created
|
||||
backups := getBackups(t, router, workspace.ID, owner.Token)
|
||||
assert.NotEmpty(t, backups)
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing Utilities Structure
|
||||
|
||||
**Create `testing.go` or `testing/testing.go` files with common utilities:**
|
||||
|
||||
45
Dockerfile
45
Dockerfile
@@ -253,16 +253,31 @@ PG_BIN="/usr/lib/postgresql/17/bin"
|
||||
|
||||
# Generate runtime configuration for frontend
|
||||
echo "Generating runtime configuration..."
|
||||
cat > /app/ui/build/runtime-config.js << 'JSEOF'
|
||||
|
||||
# Detect if email is configured (both SMTP_HOST and DATABASUS_URL must be set)
|
||||
if [ -n "\${SMTP_HOST:-}" ] && [ -n "\${DATABASUS_URL:-}" ]; then
|
||||
IS_EMAIL_CONFIGURED="true"
|
||||
else
|
||||
IS_EMAIL_CONFIGURED="false"
|
||||
fi
|
||||
|
||||
cat > /app/ui/build/runtime-config.js <<JSEOF
|
||||
// Runtime configuration injected at container startup
|
||||
// This file is generated dynamically and should not be edited manually
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
IS_CLOUD: '\${IS_CLOUD:-false}',
|
||||
GITHUB_CLIENT_ID: '\${GITHUB_CLIENT_ID:-}',
|
||||
GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}'
|
||||
GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}',
|
||||
IS_EMAIL_CONFIGURED: '\$IS_EMAIL_CONFIGURED'
|
||||
};
|
||||
JSEOF
|
||||
|
||||
# Inject analytics script if provided
|
||||
if [ -n "\${ANALYTICS_SCRIPT:-}" ]; then
|
||||
echo "Injecting analytics script..."
|
||||
sed -i "s#</head># \${ANALYTICS_SCRIPT}\n </head>#" /app/ui/build/index.html
|
||||
fi
|
||||
|
||||
# Ensure proper ownership of data directory
|
||||
echo "Setting up data directory permissions..."
|
||||
mkdir -p /databasus-data/pgdata
|
||||
@@ -384,6 +399,32 @@ SQL
|
||||
|
||||
# Start the main application
|
||||
echo "Starting Databasus application..."
|
||||
|
||||
# Check and warn about external database/Valkey usage
|
||||
if [ -n "\${DANGEROUS_EXTERNAL_DATABASE_DSN:-}" ]; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "WARNING: Using external database"
|
||||
echo "=========================================="
|
||||
echo "DANGEROUS_EXTERNAL_DATABASE_DSN is set."
|
||||
echo "Application will connect to external PostgreSQL instead of internal instance."
|
||||
echo "Internal PostgreSQL is still running in the background."
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "\${DANGEROUS_VALKEY_HOST:-}" ]; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "WARNING: Using external Valkey"
|
||||
echo "=========================================="
|
||||
echo "DANGEROUS_VALKEY_HOST is set."
|
||||
echo "Application will connect to external Valkey instead of internal instance."
|
||||
echo "Internal Valkey is still running in the background."
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
exec ./main
|
||||
EOF
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ DEV_DB_PASSWORD=Q1234567
|
||||
ENV_MODE=development
|
||||
# logging
|
||||
SHOW_DB_INSTALLATION_VERIFICATION_LOGS=true
|
||||
VICTORIA_LOGS_URL=http://localhost:9428
|
||||
VICTORIA_LOGS_PASSWORD=devpassword
|
||||
# tests
|
||||
TEST_LOCALHOST=localhost
|
||||
IS_SKIP_EXTERNAL_RESOURCES_TESTS=false
|
||||
|
||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -18,4 +18,5 @@ pgdata-for-restore/
|
||||
temp/
|
||||
cmd.exe
|
||||
temp/
|
||||
valkey-data/
|
||||
valkey-data/
|
||||
victoria-logs-data/
|
||||
@@ -185,6 +185,9 @@ func startServerWithGracefulShutdown(log *slog.Logger, app *gin.Engine) {
|
||||
<-quit
|
||||
log.Info("Shutdown signal received")
|
||||
|
||||
// Gracefully shutdown VictoriaLogs writer
|
||||
logger.ShutdownVictoriaLogs(5 * time.Second)
|
||||
|
||||
// The context is used to inform the server it has 10 seconds to finish
|
||||
// the request it is currently handling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
@@ -34,6 +34,20 @@ services:
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
# VictoriaLogs for external logging
|
||||
victoria-logs:
|
||||
image: victoriametrics/victoria-logs:latest
|
||||
container_name: victoria-logs
|
||||
ports:
|
||||
- "9428:9428"
|
||||
command:
|
||||
- -storageDataPath=/victoria-logs-data
|
||||
- -retentionPeriod=7d
|
||||
- -httpAuth.password=devpassword
|
||||
volumes:
|
||||
- ./victoria-logs-data:/victoria-logs-data
|
||||
restart: unless-stopped
|
||||
|
||||
# Test MinIO container
|
||||
test-minio:
|
||||
image: minio/minio:latest
|
||||
|
||||
@@ -22,13 +22,21 @@ const (
|
||||
|
||||
type EnvVariables struct {
|
||||
IsTesting bool
|
||||
DatabaseDsn string `env:"DATABASE_DSN" required:"true"`
|
||||
EnvMode env_utils.EnvMode `env:"ENV_MODE" required:"true"`
|
||||
PostgresesInstallDir string `env:"POSTGRES_INSTALL_DIR"`
|
||||
MysqlInstallDir string `env:"MYSQL_INSTALL_DIR"`
|
||||
MariadbInstallDir string `env:"MARIADB_INSTALL_DIR"`
|
||||
MongodbInstallDir string `env:"MONGODB_INSTALL_DIR"`
|
||||
|
||||
// Internal database
|
||||
DatabaseDsn string `env:"DATABASE_DSN" required:"true"`
|
||||
// Internal Valkey
|
||||
ValkeyHost string `env:"VALKEY_HOST" required:"true"`
|
||||
ValkeyPort string `env:"VALKEY_PORT" required:"true"`
|
||||
ValkeyUsername string `env:"VALKEY_USERNAME" required:"true"`
|
||||
ValkeyPassword string `env:"VALKEY_PASSWORD" required:"true"`
|
||||
ValkeyIsSsl bool `env:"VALKEY_IS_SSL" required:"true"`
|
||||
|
||||
IsCloud bool `env:"IS_CLOUD"`
|
||||
TestLocalhost string `env:"TEST_LOCALHOST"`
|
||||
|
||||
@@ -90,13 +98,6 @@ type EnvVariables struct {
|
||||
TestMongodb70Port string `env:"TEST_MONGODB_70_PORT"`
|
||||
TestMongodb82Port string `env:"TEST_MONGODB_82_PORT"`
|
||||
|
||||
// Valkey
|
||||
ValkeyHost string `env:"VALKEY_HOST" required:"true"`
|
||||
ValkeyPort string `env:"VALKEY_PORT" required:"true"`
|
||||
ValkeyUsername string `env:"VALKEY_USERNAME"`
|
||||
ValkeyPassword string `env:"VALKEY_PASSWORD"`
|
||||
ValkeyIsSsl bool `env:"VALKEY_IS_SSL" required:"true"`
|
||||
|
||||
// oauth
|
||||
GitHubClientID string `env:"GITHUB_CLIENT_ID"`
|
||||
GitHubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
|
||||
@@ -113,6 +114,15 @@ type EnvVariables struct {
|
||||
TestSupabaseUsername string `env:"TEST_SUPABASE_USERNAME"`
|
||||
TestSupabasePassword string `env:"TEST_SUPABASE_PASSWORD"`
|
||||
TestSupabaseDatabase string `env:"TEST_SUPABASE_DATABASE"`
|
||||
|
||||
// SMTP configuration (optional)
|
||||
SMTPHost string `env:"SMTP_HOST"`
|
||||
SMTPPort int `env:"SMTP_PORT"`
|
||||
SMTPUser string `env:"SMTP_USER"`
|
||||
SMTPPassword string `env:"SMTP_PASSWORD"`
|
||||
|
||||
// Application URL (optional) - used for email links
|
||||
DatabasusURL string `env:"DATABASUS_URL"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -195,6 +205,14 @@ func loadEnvVariables() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for external database override
|
||||
if externalDsn := os.Getenv("DANGEROUS_EXTERNAL_DATABASE_DSN"); externalDsn != "" {
|
||||
log.Warn(
|
||||
"Using DANGEROUS_EXTERNAL_DATABASE_DSN - connecting to external database instead of internal PostgreSQL",
|
||||
)
|
||||
env.DatabaseDsn = externalDsn
|
||||
}
|
||||
|
||||
if env.DatabaseDsn == "" {
|
||||
log.Error("DATABASE_DSN is empty")
|
||||
os.Exit(1)
|
||||
@@ -265,6 +283,27 @@ func loadEnvVariables() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check for external Valkey override
|
||||
if externalValkeyHost := os.Getenv("DANGEROUS_VALKEY_HOST"); externalValkeyHost != "" {
|
||||
log.Warn(
|
||||
"Using DANGEROUS_VALKEY_* variables - connecting to external Valkey instead of internal instance",
|
||||
)
|
||||
env.ValkeyHost = externalValkeyHost
|
||||
|
||||
if externalValkeyPort := os.Getenv("DANGEROUS_VALKEY_PORT"); externalValkeyPort != "" {
|
||||
env.ValkeyPort = externalValkeyPort
|
||||
}
|
||||
if externalValkeyUsername := os.Getenv("DANGEROUS_VALKEY_USERNAME"); externalValkeyUsername != "" {
|
||||
env.ValkeyUsername = externalValkeyUsername
|
||||
}
|
||||
if externalValkeyPassword := os.Getenv("DANGEROUS_VALKEY_PASSWORD"); externalValkeyPassword != "" {
|
||||
env.ValkeyPassword = externalValkeyPassword
|
||||
}
|
||||
if externalValkeyIsSsl := os.Getenv("DANGEROUS_VALKEY_IS_SSL"); externalValkeyIsSsl != "" {
|
||||
env.ValkeyIsSsl = externalValkeyIsSsl == "true"
|
||||
}
|
||||
}
|
||||
|
||||
// Store the data and temp folders one level below the root
|
||||
// (projectRoot/databasus-data -> /databasus-data)
|
||||
env.DataFolder = filepath.Join(filepath.Dir(backendRoot), "databasus-data", "backups")
|
||||
|
||||
@@ -28,6 +28,19 @@ func Test_CleanOldBackups_DeletesBackupsOlderThanStorePeriod(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -96,6 +109,19 @@ func Test_CleanOldBackups_SkipsDatabaseWithForeverStorePeriod(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -142,6 +168,19 @@ func Test_CleanExceededBackups_WhenUnderLimit_NoBackupsDeleted(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -190,6 +229,19 @@ func Test_CleanExceededBackups_WhenOverLimit_DeletesOldestBackups(t *testing.T)
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -252,6 +304,19 @@ func Test_CleanExceededBackups_SkipsInProgressBackups(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -328,6 +393,19 @@ func Test_CleanExceededBackups_WithZeroLimit_SkipsDatabase(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -376,6 +454,19 @@ func Test_GetTotalSizeByDatabase_CalculatesCorrectly(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create completed backups
|
||||
completedBackup1 := &backups_core.Backup{
|
||||
ID: uuid.New(),
|
||||
@@ -454,6 +545,19 @@ func Test_DeleteBackup_WhenStorageDeleteFails_BackupStillRemovedFromDatabase(t *
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, testStorage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(testStorage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
backup := &backups_core.Backup{
|
||||
ID: uuid.New(),
|
||||
DatabaseID: database.ID,
|
||||
|
||||
@@ -103,49 +103,6 @@ func (s *BackupsScheduler) IsSchedulerRunning() bool {
|
||||
return s.lastBackupTime.After(time.Now().UTC().Add(-schedulerHealthcheckThreshold))
|
||||
}
|
||||
|
||||
func (s *BackupsScheduler) failBackupsInProgress() error {
|
||||
backupsInProgress, err := s.backupRepository.FindByStatus(backups_core.BackupStatusInProgress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, backup := range backupsInProgress {
|
||||
if err := s.taskCancelManager.CancelTask(backup.ID); err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to cancel backup via task cancel manager",
|
||||
"backupId",
|
||||
backup.ID,
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(backup.DatabaseID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get backup config by database ID", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
failMessage := "Backup failed due to application restart"
|
||||
backup.FailMessage = &failMessage
|
||||
backup.Status = backups_core.BackupStatusFailed
|
||||
backup.BackupSizeMb = 0
|
||||
|
||||
s.backuperNode.SendBackupNotification(
|
||||
backupConfig,
|
||||
backup,
|
||||
backups_config.NotificationBackupFailed,
|
||||
&failMessage,
|
||||
)
|
||||
|
||||
if err := s.backupRepository.Save(backup); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BackupsScheduler) StartBackup(databaseID uuid.UUID, isCallNotifier bool) {
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(databaseID)
|
||||
if err != nil {
|
||||
@@ -197,6 +154,7 @@ func (s *BackupsScheduler) StartBackup(databaseID uuid.UUID, isCallNotifier bool
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("make backup")
|
||||
backup := &backups_core.Backup{
|
||||
DatabaseID: backupConfig.DatabaseID,
|
||||
StorageID: *backupConfig.StorageID,
|
||||
@@ -369,6 +327,49 @@ func (s *BackupsScheduler) runPendingBackups() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BackupsScheduler) failBackupsInProgress() error {
|
||||
backupsInProgress, err := s.backupRepository.FindByStatus(backups_core.BackupStatusInProgress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, backup := range backupsInProgress {
|
||||
if err := s.taskCancelManager.CancelTask(backup.ID); err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to cancel backup via task cancel manager",
|
||||
"backupId",
|
||||
backup.ID,
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(backup.DatabaseID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get backup config by database ID", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
failMessage := "Backup failed due to application restart"
|
||||
backup.FailMessage = &failMessage
|
||||
backup.Status = backups_core.BackupStatusFailed
|
||||
backup.BackupSizeMb = 0
|
||||
|
||||
s.backuperNode.SendBackupNotification(
|
||||
backupConfig,
|
||||
backup,
|
||||
backups_config.NotificationBackupFailed,
|
||||
&failMessage,
|
||||
)
|
||||
|
||||
if err := s.backupRepository.Save(backup); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BackupsScheduler) calculateLeastBusyNode() (*uuid.UUID, error) {
|
||||
nodes, err := s.backupNodesRegistry.GetAvailableNodes()
|
||||
if err != nil {
|
||||
|
||||
@@ -80,7 +80,7 @@ func Test_GetBackups_PermissionsEnforced(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, _ := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, _, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -122,6 +122,12 @@ func Test_GetBackups_PermissionsEnforced(t *testing.T) {
|
||||
} else {
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -218,6 +224,10 @@ func Test_CreateBackup_PermissionsEnforced(t *testing.T) {
|
||||
} else {
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -261,6 +271,10 @@ func Test_CreateBackup_AuditLogWritten(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Audit log for backup creation not found")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DeleteBackup_PermissionsEnforced(t *testing.T) {
|
||||
@@ -314,7 +328,7 @@ func Test_DeleteBackup_PermissionsEnforced(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -358,6 +372,12 @@ func Test_DeleteBackup_PermissionsEnforced(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(response.Backups))
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -367,7 +387,7 @@ func Test_DeleteBackup_AuditLogWritten(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
test_utils.MakeDeleteRequest(
|
||||
t,
|
||||
@@ -398,6 +418,12 @@ func Test_DeleteBackup_AuditLogWritten(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Audit log for backup deletion not found")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_GenerateDownloadToken_PermissionsEnforced(t *testing.T) {
|
||||
@@ -444,7 +470,7 @@ func Test_GenerateDownloadToken_PermissionsEnforced(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -488,6 +514,12 @@ func Test_GenerateDownloadToken_PermissionsEnforced(t *testing.T) {
|
||||
} else {
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -497,7 +529,7 @@ func Test_DownloadBackup_WithValidToken_Success(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Generate download token
|
||||
var tokenResponse backups_download.GenerateDownloadTokenResponse
|
||||
@@ -524,6 +556,12 @@ func Test_DownloadBackup_WithValidToken_Success(t *testing.T) {
|
||||
contentDisposition := testResp.Headers.Get("Content-Disposition")
|
||||
assert.Contains(t, contentDisposition, "attachment")
|
||||
assert.Contains(t, contentDisposition, tokenResponse.Filename)
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_WithoutToken_Unauthorized(t *testing.T) {
|
||||
@@ -531,7 +569,7 @@ func Test_DownloadBackup_WithoutToken_Unauthorized(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Try to download without token
|
||||
testResp := test_utils.MakeGetRequest(
|
||||
@@ -543,6 +581,12 @@ func Test_DownloadBackup_WithoutToken_Unauthorized(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, string(testResp.Body), "download token is required")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_WithInvalidToken_Unauthorized(t *testing.T) {
|
||||
@@ -550,7 +594,7 @@ func Test_DownloadBackup_WithInvalidToken_Unauthorized(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Try to download with invalid token
|
||||
testResp := test_utils.MakeGetRequest(
|
||||
@@ -562,6 +606,12 @@ func Test_DownloadBackup_WithInvalidToken_Unauthorized(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, string(testResp.Body), "invalid or expired download token")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_WithExpiredToken_Unauthorized(t *testing.T) {
|
||||
@@ -569,7 +619,7 @@ func Test_DownloadBackup_WithExpiredToken_Unauthorized(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Get user for token generation
|
||||
userService := users_services.GetUserService()
|
||||
@@ -611,6 +661,12 @@ func Test_DownloadBackup_WithExpiredToken_Unauthorized(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.False(t, found, "Audit log should NOT be created for failed download with expired token")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_TokenUsedOnce_CannotReuseToken(t *testing.T) {
|
||||
@@ -618,7 +674,7 @@ func Test_DownloadBackup_TokenUsedOnce_CannotReuseToken(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Generate download token
|
||||
var tokenResponse backups_download.GenerateDownloadTokenResponse
|
||||
@@ -651,6 +707,12 @@ func Test_DownloadBackup_TokenUsedOnce_CannotReuseToken(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, string(testResp.Body), "invalid or expired download token")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_WithDifferentBackupToken_Unauthorized(t *testing.T) {
|
||||
@@ -705,6 +767,13 @@ func Test_DownloadBackup_WithDifferentBackupToken_Unauthorized(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, string(testResp.Body), "invalid or expired download token")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database1)
|
||||
databases.RemoveTestDatabase(database2)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_AuditLogWritten(t *testing.T) {
|
||||
@@ -712,7 +781,7 @@ func Test_DownloadBackup_AuditLogWritten(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Generate download token
|
||||
var tokenResponse backups_download.GenerateDownloadTokenResponse
|
||||
@@ -756,6 +825,12 @@ func Test_DownloadBackup_AuditLogWritten(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Audit log for backup download not found")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_ProperFilenameForPostgreSQL(t *testing.T) {
|
||||
@@ -856,6 +931,12 @@ func Test_DownloadBackup_ProperFilenameForPostgreSQL(t *testing.T) {
|
||||
contentDisposition,
|
||||
"Filename should contain timestamp",
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -948,6 +1029,12 @@ func Test_CancelBackup_InProgressBackup_SuccessfullyCancelled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.True(t, foundCancelLog, "Cancel audit log should be created")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_ConcurrentDownloadPrevention(t *testing.T) {
|
||||
@@ -955,7 +1042,7 @@ func Test_ConcurrentDownloadPrevention(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
var token1Response backups_download.GenerateDownloadTokenResponse
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
@@ -1003,6 +1090,12 @@ func Test_ConcurrentDownloadPrevention(t *testing.T) {
|
||||
if !service.IsDownloadInProgress(owner.UserID) {
|
||||
t.Log("Warning: First download completed before we could test concurrency")
|
||||
<-downloadComplete
|
||||
|
||||
// Cleanup before early return
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1049,6 +1142,12 @@ func Test_ConcurrentDownloadPrevention(t *testing.T) {
|
||||
t.Log(
|
||||
"Successfully prevented concurrent downloads and allowed subsequent downloads after completion",
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_GenerateDownloadToken_BlockedWhenDownloadInProgress(t *testing.T) {
|
||||
@@ -1056,7 +1155,7 @@ func Test_GenerateDownloadToken_BlockedWhenDownloadInProgress(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
var token1Response backups_download.GenerateDownloadTokenResponse
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
@@ -1092,6 +1191,12 @@ func Test_GenerateDownloadToken_BlockedWhenDownloadInProgress(t *testing.T) {
|
||||
if !service.IsDownloadInProgress(owner.UserID) {
|
||||
t.Log("Warning: First download completed before we could test token generation blocking")
|
||||
<-downloadComplete
|
||||
|
||||
// Cleanup before early return
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1131,6 +1236,12 @@ func Test_GenerateDownloadToken_BlockedWhenDownloadInProgress(t *testing.T) {
|
||||
t.Log(
|
||||
"Successfully blocked token generation during download and allowed generation after completion",
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func createTestRouter() *gin.Engine {
|
||||
@@ -1222,7 +1333,7 @@ func createTestDatabaseWithBackups(
|
||||
workspace *workspaces_models.Workspace,
|
||||
owner *users_dto.SignInResponseDTO,
|
||||
router *gin.Engine,
|
||||
) (*databases.Database, *backups_core.Backup) {
|
||||
) (*databases.Database, *backups_core.Backup, *storages.Storage) {
|
||||
database := createTestDatabase("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
@@ -1242,7 +1353,7 @@ func createTestDatabaseWithBackups(
|
||||
|
||||
backup := createTestBackup(database, owner)
|
||||
|
||||
return database, backup
|
||||
return database, backup, storage
|
||||
}
|
||||
|
||||
func createTestBackup(
|
||||
@@ -1320,7 +1431,7 @@ func Test_BandwidthThrottling_SingleDownload_Uses75Percent(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
bandwidthManager := backups_download.GetBandwidthManager()
|
||||
initialCount := bandwidthManager.GetActiveDownloadCount()
|
||||
@@ -1370,6 +1481,12 @@ func Test_BandwidthThrottling_SingleDownload_Uses75Percent(t *testing.T) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
finalCount := bandwidthManager.GetActiveDownloadCount()
|
||||
assert.Equal(t, initialCount, finalCount, "Download should be unregistered after completion")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_BandwidthThrottling_MultipleDownloads_ShareBandwidth(t *testing.T) {
|
||||
@@ -1489,6 +1606,12 @@ func Test_BandwidthThrottling_MultipleDownloads_ShareBandwidth(t *testing.T) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
finalCount := bandwidthManager.GetActiveDownloadCount()
|
||||
assert.Equal(t, initialCount, finalCount, "All downloads should be unregistered")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_BandwidthThrottling_DynamicAdjustment(t *testing.T) {
|
||||
@@ -1577,4 +1700,10 @@ func Test_BandwidthThrottling_DynamicAdjustment(t *testing.T) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
finalCount := bandwidthManager.GetActiveDownloadCount()
|
||||
assert.Equal(t, initialCount, finalCount, "All downloads completed and unregistered")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -92,6 +93,11 @@ func Test_SaveBackupConfig_PermissionsEnforced(t *testing.T) {
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
@@ -155,6 +161,11 @@ func Test_SaveBackupConfig_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *test
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
timeOfDay := "04:00"
|
||||
@@ -245,6 +256,11 @@ func Test_GetBackupConfigByDbID_PermissionsEnforced(t *testing.T) {
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
@@ -293,6 +309,11 @@ func Test_GetBackupConfigByDbID_ReturnsDefaultConfigForNewDatabase(t *testing.T)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var response BackupConfig
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
@@ -331,6 +352,11 @@ func Test_GetDatabasePlan_ForNewDatabase_PlanAlwaysReturned(t *testing.T) {
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var response plans.DatabasePlan
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
@@ -354,6 +380,11 @@ func Test_SaveBackupConfig_WhenPlanLimitsAreAdjusted_ValidationEnforced(t *testi
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Get plan via API (triggers auto-creation)
|
||||
var plan plans.DatabasePlan
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
@@ -533,6 +564,10 @@ func Test_IsStorageUsing_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var testUserToken string
|
||||
if tt.isStorageOwner {
|
||||
testUserToken = storageOwner.Token
|
||||
@@ -565,10 +600,6 @@ func Test_IsStorageUsing_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
assert.Contains(t, string(testResp.Body), "error")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -580,6 +611,11 @@ func Test_SaveBackupConfig_WithEncryptionNone_ConfigSaved(t *testing.T) {
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -619,6 +655,11 @@ func Test_SaveBackupConfig_WithEncryptionEncrypted_ConfigSaved(t *testing.T) {
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -729,6 +770,15 @@ func Test_TransferDatabase_PermissionsEnforced(t *testing.T) {
|
||||
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
// Cleanup in correct order to avoid foreign key violations
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond) // Wait for cascade delete of backup_config
|
||||
storages.RemoveTestStorage(targetStorage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
@@ -821,6 +871,12 @@ func Test_TransferDatabase_NonMemberInSourceWorkspace_CannotTransfer(t *testing.
|
||||
router,
|
||||
)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
request := TransferDatabaseRequest{
|
||||
TargetWorkspaceID: targetWorkspace.ID,
|
||||
}
|
||||
@@ -861,6 +917,12 @@ func Test_TransferDatabase_NonMemberInTargetWorkspace_CannotTransfer(t *testing.
|
||||
router,
|
||||
)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
request := TransferDatabaseRequest{
|
||||
TargetWorkspaceID: targetWorkspace.ID,
|
||||
}
|
||||
@@ -888,6 +950,13 @@ func Test_TransferDatabase_ToNewStorage_DatabaseTransferd(t *testing.T) {
|
||||
sourceStorage := createTestStorage(sourceWorkspace.ID)
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond) // Wait for cascading deletes
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -967,6 +1036,13 @@ func Test_TransferDatabase_WithExistingStorage_DatabaseAndStorageTransferd(t *te
|
||||
database := createTestDatabaseViaAPI("Test Database", sourceWorkspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(sourceWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond) // Wait for cascading deletes
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -1056,6 +1132,14 @@ func Test_TransferDatabase_StorageHasOtherDBs_CannotTransfer(t *testing.T) {
|
||||
)
|
||||
storage := createTestStorage(sourceWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database1)
|
||||
databases.RemoveTestDatabase(database2)
|
||||
time.Sleep(200 * time.Millisecond) // Wait for cascading deletes
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest1 := BackupConfig{
|
||||
DatabaseID: database1.ID,
|
||||
@@ -1138,6 +1222,14 @@ func Test_TransferDatabase_WithNotifiers_NotifiersTransferred(t *testing.T) {
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
notifier := notifiers.CreateTestNotifier(sourceWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
database.Notifiers = []notifiers.Notifier{*notifier}
|
||||
var updatedDatabase databases.Database
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
@@ -1241,6 +1333,15 @@ func Test_TransferDatabase_NotifierHasOtherDBs_NotifierSkipped(t *testing.T) {
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
sharedNotifier := notifiers.CreateTestNotifier(sourceWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database1)
|
||||
databases.RemoveTestDatabase(database2)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(sharedNotifier)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
database1.Notifiers = []notifiers.Notifier{*sharedNotifier}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
@@ -1353,6 +1454,16 @@ func Test_TransferDatabase_WithMultipleNotifiers_OnlyExclusiveOnesTransferred(t
|
||||
exclusiveNotifier := notifiers.CreateTestNotifier(sourceWorkspace.ID)
|
||||
sharedNotifier := notifiers.CreateTestNotifier(sourceWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database1)
|
||||
databases.RemoveTestDatabase(database2)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(exclusiveNotifier)
|
||||
notifiers.RemoveTestNotifier(sharedNotifier)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
database1.Notifiers = []notifiers.Notifier{*exclusiveNotifier, *sharedNotifier}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
@@ -1464,6 +1575,14 @@ func Test_TransferDatabase_WithTargetNotifiers_NotifiersAssigned(t *testing.T) {
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
targetNotifier := notifiers.CreateTestNotifier(targetWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(targetNotifier)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -1535,6 +1654,15 @@ func Test_TransferDatabase_TargetNotifierFromDifferentWorkspace_ReturnsBadReques
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
wrongNotifier := notifiers.CreateTestNotifier(otherWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(wrongNotifier)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(otherWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -1592,6 +1720,14 @@ func Test_TransferDatabase_TargetStorageFromDifferentWorkspace_ReturnsBadRequest
|
||||
sourceStorage := createTestStorage(sourceWorkspace.ID)
|
||||
wrongStorage := createTestStorage(otherWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(otherWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -1737,7 +1873,12 @@ func Test_SaveBackupConfig_WithSystemStorage_CanBeUsedByAnyDatabase(t *testing.T
|
||||
assert.Equal(t, savedSystemStorage.ID, *savedConfig.StorageID)
|
||||
assert.True(t, savedConfig.IsBackupsEnabled)
|
||||
|
||||
// Cleanup: database first (cascades to backup_config), then storages, then workspaces
|
||||
databases.RemoveTestDatabase(databaseA)
|
||||
storages.RemoveTestStorage(regularStorageB.ID)
|
||||
storages.RemoveTestStorage(savedSystemStorage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspaceA, router)
|
||||
workspaces_testing.RemoveTestWorkspace(workspaceB, router)
|
||||
}
|
||||
|
||||
func createTestDatabaseViaAPI(
|
||||
|
||||
@@ -26,6 +26,12 @@ func Test_AttachNotifierFromSameWorkspace_SuccessfullyAttached(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
database.Notifiers = []notifiers.Notifier{*notifier}
|
||||
|
||||
var response databases.Database
|
||||
@@ -55,6 +61,13 @@ func Test_AttachNotifierFromDifferentWorkspace_ReturnsForbidden(t *testing.T) {
|
||||
workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router)
|
||||
notifier := notifiers.CreateTestNotifier(workspace2.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
}()
|
||||
|
||||
database.Notifiers = []notifiers.Notifier{*notifier}
|
||||
|
||||
testResp := test_utils.MakePostRequest(
|
||||
@@ -77,6 +90,12 @@ func Test_DeleteNotifierWithAttachedDatabases_CannotDelete(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
database.Notifiers = []notifiers.Notifier{*notifier}
|
||||
|
||||
var response databases.Database
|
||||
@@ -114,6 +133,13 @@ func Test_TransferNotifierWithAttachedDatabase_CannotTransfer(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
database.Notifiers = []notifiers.Notifier{*notifier}
|
||||
|
||||
var response databases.Database
|
||||
|
||||
@@ -27,6 +27,12 @@ func Test_AttachStorageFromSameWorkspace_SuccessfullyAttached(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -72,6 +78,13 @@ func Test_AttachStorageFromDifferentWorkspace_ReturnsForbidden(t *testing.T) {
|
||||
workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router)
|
||||
storage := createTestStorage(workspace2.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -110,6 +123,12 @@ func Test_DeleteStorageWithAttachedDatabases_CannotDelete(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -163,6 +182,13 @@ func Test_TransferStorageWithAttachedDatabase_CannotTransfer(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
|
||||
@@ -25,80 +25,6 @@ import (
|
||||
"databasus-backend/internal/util/tools"
|
||||
)
|
||||
|
||||
func createTestRouter() *gin.Engine {
|
||||
router := workspaces_testing.CreateTestRouter(
|
||||
workspaces_controllers.GetWorkspaceController(),
|
||||
workspaces_controllers.GetMembershipController(),
|
||||
GetDatabaseController(),
|
||||
)
|
||||
return router
|
||||
}
|
||||
|
||||
func getTestPostgresConfig() *postgresql.PostgresqlDatabase {
|
||||
env := config.GetEnv()
|
||||
port, err := strconv.Atoi(env.TestPostgres16Port)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_POSTGRES_16_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
return &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
CpuCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func getTestMariadbConfig() *mariadb.MariadbDatabase {
|
||||
env := config.GetEnv()
|
||||
portStr := env.TestMariadb1011Port
|
||||
if portStr == "" {
|
||||
portStr = "33111"
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_MARIADB_1011_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
return &mariadb.MariadbDatabase{
|
||||
Version: tools.MariadbVersion1011,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
}
|
||||
}
|
||||
|
||||
func getTestMongodbConfig() *mongodb.MongodbDatabase {
|
||||
env := config.GetEnv()
|
||||
portStr := env.TestMongodb70Port
|
||||
if portStr == "" {
|
||||
portStr = "27070"
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_MONGODB_70_PORT: %v", err))
|
||||
}
|
||||
|
||||
return &mongodb.MongodbDatabase{
|
||||
Version: tools.MongodbVersion7,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "root",
|
||||
Password: "rootpassword",
|
||||
Database: "testdb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
CpuCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func Test_CreateDatabase_PermissionsEnforced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -142,6 +68,7 @@ func Test_CreateDatabase_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -180,6 +107,7 @@ func Test_CreateDatabase_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
|
||||
if tt.expectSuccess {
|
||||
defer RemoveTestDatabase(&response)
|
||||
assert.Equal(t, "Test Database", response.Name)
|
||||
assert.NotEqual(t, uuid.Nil, response.ID)
|
||||
} else {
|
||||
@@ -193,6 +121,7 @@ func Test_CreateDatabase_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testin
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
@@ -258,8 +187,10 @@ func Test_UpdateDatabase_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -305,8 +236,10 @@ func Test_UpdateDatabase_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testin
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
database.Name = "Hacked Name"
|
||||
@@ -366,6 +299,7 @@ func Test_DeleteDatabase_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
@@ -396,6 +330,7 @@ func Test_DeleteDatabase_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
|
||||
if !tt.expectSuccess {
|
||||
defer RemoveTestDatabase(database)
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
})
|
||||
@@ -439,8 +374,10 @@ func Test_GetDatabase_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
var testUser string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -517,9 +454,12 @@ func Test_GetDatabasesByWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
createTestDatabaseViaAPI("Database 1", workspace.ID, owner.Token, router)
|
||||
createTestDatabaseViaAPI("Database 2", workspace.ID, owner.Token, router)
|
||||
db1 := createTestDatabaseViaAPI("Database 1", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(db1)
|
||||
db2 := createTestDatabaseViaAPI("Database 2", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(db2)
|
||||
|
||||
var testUser string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -561,10 +501,14 @@ func Test_GetDatabasesByWorkspace_WhenMultipleDatabasesExist_ReturnsCorrectCount
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
createTestDatabaseViaAPI("Database 1", workspace.ID, owner.Token, router)
|
||||
createTestDatabaseViaAPI("Database 2", workspace.ID, owner.Token, router)
|
||||
createTestDatabaseViaAPI("Database 3", workspace.ID, owner.Token, router)
|
||||
db1 := createTestDatabaseViaAPI("Database 1", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(db1)
|
||||
db2 := createTestDatabaseViaAPI("Database 2", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(db2)
|
||||
db3 := createTestDatabaseViaAPI("Database 3", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(db3)
|
||||
|
||||
var response []Database
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
@@ -583,14 +527,19 @@ func Test_GetDatabasesByWorkspace_EnsuresCrossWorkspaceIsolation(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace1 := workspaces_testing.CreateTestWorkspace("Workspace 1", owner1, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
|
||||
owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
|
||||
createTestDatabaseViaAPI("Workspace1 DB1", workspace1.ID, owner1.Token, router)
|
||||
createTestDatabaseViaAPI("Workspace1 DB2", workspace1.ID, owner1.Token, router)
|
||||
workspace1Db1 := createTestDatabaseViaAPI("Workspace1 DB1", workspace1.ID, owner1.Token, router)
|
||||
defer RemoveTestDatabase(workspace1Db1)
|
||||
workspace1Db2 := createTestDatabaseViaAPI("Workspace1 DB2", workspace1.ID, owner1.Token, router)
|
||||
defer RemoveTestDatabase(workspace1Db2)
|
||||
|
||||
createTestDatabaseViaAPI("Workspace2 DB1", workspace2.ID, owner2.Token, router)
|
||||
workspace2Db1 := createTestDatabaseViaAPI("Workspace2 DB1", workspace2.ID, owner2.Token, router)
|
||||
defer RemoveTestDatabase(workspace2Db1)
|
||||
|
||||
var workspace1Dbs []Database
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
@@ -667,8 +616,10 @@ func Test_CopyDatabase_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -700,6 +651,7 @@ func Test_CopyDatabase_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
|
||||
if tt.expectSuccess {
|
||||
defer RemoveTestDatabase(&response)
|
||||
assert.NotEqual(t, database.ID, response.ID)
|
||||
assert.Contains(t, response.Name, "(Copy)")
|
||||
} else {
|
||||
@@ -713,8 +665,10 @@ func Test_CopyDatabase_CopyStaysInSameWorkspace(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
var response Database
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
@@ -727,139 +681,14 @@ func Test_CopyDatabase_CopyStaysInSameWorkspace(t *testing.T) {
|
||||
&response,
|
||||
)
|
||||
|
||||
defer RemoveTestDatabase(&response)
|
||||
|
||||
assert.NotEqual(t, database.ID, response.ID)
|
||||
assert.Equal(t, "Test Database (Copy)", response.Name)
|
||||
assert.Equal(t, workspace.ID, *response.WorkspaceID)
|
||||
assert.Equal(t, database.Type, response.Type)
|
||||
}
|
||||
|
||||
func Test_TestConnection_PermissionsEnforced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isMember bool
|
||||
isGlobalAdmin bool
|
||||
expectAccessGranted bool
|
||||
expectedStatusCodeOnErr int
|
||||
}{
|
||||
{
|
||||
name: "workspace member can test connection",
|
||||
isMember: true,
|
||||
isGlobalAdmin: false,
|
||||
expectAccessGranted: true,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "non-member cannot test connection",
|
||||
isMember: false,
|
||||
isGlobalAdmin: false,
|
||||
expectAccessGranted: false,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "global admin can test connection",
|
||||
isMember: false,
|
||||
isGlobalAdmin: true,
|
||||
expectAccessGranted: true,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
var testUser string
|
||||
if tt.isGlobalAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
testUser = admin.Token
|
||||
} else if tt.isMember {
|
||||
testUser = owner.Token
|
||||
} else {
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
testUser = nonMember.Token
|
||||
}
|
||||
|
||||
w := workspaces_testing.MakeAPIRequest(
|
||||
router,
|
||||
"POST",
|
||||
"/api/v1/databases/"+database.ID.String()+"/test-connection",
|
||||
"Bearer "+testUser,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
if tt.expectAccessGranted {
|
||||
assert.True(
|
||||
t,
|
||||
w.Code == http.StatusOK ||
|
||||
(w.Code == http.StatusBadRequest && strings.Contains(body, "connect")),
|
||||
"Expected 200 OK or 400 with connection error, got %d: %s",
|
||||
w.Code,
|
||||
body,
|
||||
)
|
||||
} else {
|
||||
assert.Equal(t, tt.expectedStatusCodeOnErr, w.Code)
|
||||
assert.Contains(t, body, "insufficient permissions")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createTestDatabaseViaAPI(
|
||||
name string,
|
||||
workspaceID uuid.UUID,
|
||||
token string,
|
||||
router *gin.Engine,
|
||||
) *Database {
|
||||
env := config.GetEnv()
|
||||
port, err := strconv.Atoi(env.TestPostgres16Port)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_POSTGRES_16_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
request := Database{
|
||||
Name: name,
|
||||
WorkspaceID: &workspaceID,
|
||||
Type: DatabaseTypePostgres,
|
||||
Postgresql: &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
CpuCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
w := workspaces_testing.MakeAPIRequest(
|
||||
router,
|
||||
"POST",
|
||||
"/api/v1/databases/create",
|
||||
"Bearer "+token,
|
||||
request,
|
||||
)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
panic(
|
||||
fmt.Sprintf("Failed to create database. Status: %d, Body: %s", w.Code, w.Body.String()),
|
||||
)
|
||||
}
|
||||
|
||||
var database Database
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &database); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &database
|
||||
}
|
||||
|
||||
func Test_CreateDatabase_PasswordIsEncryptedInDB(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
@@ -1141,3 +970,206 @@ func Test_DatabaseSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_TestConnection_PermissionsEnforced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isMember bool
|
||||
isGlobalAdmin bool
|
||||
expectAccessGranted bool
|
||||
expectedStatusCodeOnErr int
|
||||
}{
|
||||
{
|
||||
name: "workspace member can test connection",
|
||||
isMember: true,
|
||||
isGlobalAdmin: false,
|
||||
expectAccessGranted: true,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "non-member cannot test connection",
|
||||
isMember: false,
|
||||
isGlobalAdmin: false,
|
||||
expectAccessGranted: false,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "global admin can test connection",
|
||||
isMember: false,
|
||||
isGlobalAdmin: true,
|
||||
expectAccessGranted: true,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
var testUser string
|
||||
if tt.isGlobalAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
testUser = admin.Token
|
||||
} else if tt.isMember {
|
||||
testUser = owner.Token
|
||||
} else {
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
testUser = nonMember.Token
|
||||
}
|
||||
|
||||
w := workspaces_testing.MakeAPIRequest(
|
||||
router,
|
||||
"POST",
|
||||
"/api/v1/databases/"+database.ID.String()+"/test-connection",
|
||||
"Bearer "+testUser,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
if tt.expectAccessGranted {
|
||||
assert.True(
|
||||
t,
|
||||
w.Code == http.StatusOK ||
|
||||
(w.Code == http.StatusBadRequest && strings.Contains(body, "connect")),
|
||||
"Expected 200 OK or 400 with connection error, got %d: %s",
|
||||
w.Code,
|
||||
body,
|
||||
)
|
||||
} else {
|
||||
assert.Equal(t, tt.expectedStatusCodeOnErr, w.Code)
|
||||
assert.Contains(t, body, "insufficient permissions")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createTestDatabaseViaAPI(
|
||||
name string,
|
||||
workspaceID uuid.UUID,
|
||||
token string,
|
||||
router *gin.Engine,
|
||||
) *Database {
|
||||
env := config.GetEnv()
|
||||
port, err := strconv.Atoi(env.TestPostgres16Port)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_POSTGRES_16_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
request := Database{
|
||||
Name: name,
|
||||
WorkspaceID: &workspaceID,
|
||||
Type: DatabaseTypePostgres,
|
||||
Postgresql: &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
CpuCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
w := workspaces_testing.MakeAPIRequest(
|
||||
router,
|
||||
"POST",
|
||||
"/api/v1/databases/create",
|
||||
"Bearer "+token,
|
||||
request,
|
||||
)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
panic(
|
||||
fmt.Sprintf("Failed to create database. Status: %d, Body: %s", w.Code, w.Body.String()),
|
||||
)
|
||||
}
|
||||
|
||||
var database Database
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &database); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &database
|
||||
}
|
||||
|
||||
func createTestRouter() *gin.Engine {
|
||||
router := workspaces_testing.CreateTestRouter(
|
||||
workspaces_controllers.GetWorkspaceController(),
|
||||
workspaces_controllers.GetMembershipController(),
|
||||
GetDatabaseController(),
|
||||
)
|
||||
return router
|
||||
}
|
||||
|
||||
func getTestPostgresConfig() *postgresql.PostgresqlDatabase {
|
||||
env := config.GetEnv()
|
||||
port, err := strconv.Atoi(env.TestPostgres16Port)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_POSTGRES_16_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
return &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
CpuCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func getTestMariadbConfig() *mariadb.MariadbDatabase {
|
||||
env := config.GetEnv()
|
||||
portStr := env.TestMariadb1011Port
|
||||
if portStr == "" {
|
||||
portStr = "33111"
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_MARIADB_1011_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
return &mariadb.MariadbDatabase{
|
||||
Version: tools.MariadbVersion1011,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
}
|
||||
}
|
||||
|
||||
func getTestMongodbConfig() *mongodb.MongodbDatabase {
|
||||
env := config.GetEnv()
|
||||
portStr := env.TestMongodb70Port
|
||||
if portStr == "" {
|
||||
portStr = "27070"
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_MONGODB_70_PORT: %v", err))
|
||||
}
|
||||
|
||||
return &mongodb.MongodbDatabase{
|
||||
Version: tools.MongodbVersion7,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "root",
|
||||
Password: "rootpassword",
|
||||
Database: "testdb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
CpuCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"databasus-backend/internal/features/databases/databases/postgresql"
|
||||
"databasus-backend/internal/features/notifiers"
|
||||
"databasus-backend/internal/features/storages"
|
||||
"databasus-backend/internal/storage"
|
||||
"databasus-backend/internal/util/tools"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -104,6 +105,19 @@ func CreateTestDatabase(
|
||||
}
|
||||
|
||||
func RemoveTestDatabase(database *Database) {
|
||||
// Delete backups and backup configs associated with this database
|
||||
// We hardcode SQL here because we cannot call backups feature due to DI inversion
|
||||
// (databases package cannot import backups package as backups already imports databases)
|
||||
db := storage.GetDb()
|
||||
|
||||
if err := db.Exec("DELETE FROM backups WHERE database_id = ?", database.ID).Error; err != nil {
|
||||
panic(fmt.Sprintf("failed to delete backups: %v", err))
|
||||
}
|
||||
|
||||
if err := db.Exec("DELETE FROM backup_configs WHERE database_id = ?", database.ID).Error; err != nil {
|
||||
panic(fmt.Sprintf("failed to delete backup config: %v", err))
|
||||
}
|
||||
|
||||
err := databaseRepository.Delete(database.ID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
22
backend/internal/features/email/di.go
Normal file
22
backend/internal/features/email/di.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/config"
|
||||
"databasus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var env = config.GetEnv()
|
||||
var log = logger.GetLogger()
|
||||
|
||||
var emailSMTPSender = &EmailSMTPSender{
|
||||
log,
|
||||
env.SMTPHost,
|
||||
env.SMTPPort,
|
||||
env.SMTPUser,
|
||||
env.SMTPPassword,
|
||||
env.SMTPHost != "" && env.SMTPPort != 0,
|
||||
}
|
||||
|
||||
func GetEmailSMTPSender() *EmailSMTPSender {
|
||||
return emailSMTPSender
|
||||
}
|
||||
245
backend/internal/features/email/email.go
Normal file
245
backend/internal/features/email/email.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ImplicitTLSPort = 465
|
||||
DefaultTimeout = 5 * time.Second
|
||||
DefaultHelloName = "localhost"
|
||||
MIMETypeHTML = "text/html"
|
||||
MIMECharsetUTF8 = "UTF-8"
|
||||
)
|
||||
|
||||
type EmailSMTPSender struct {
|
||||
logger *slog.Logger
|
||||
smtpHost string
|
||||
smtpPort int
|
||||
smtpUser string
|
||||
smtpPassword string
|
||||
isConfigured bool
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) SendEmail(to, subject, body string) error {
|
||||
if !s.isConfigured {
|
||||
s.logger.Warn("Skipping email send, SMTP not initialized", "to", to, "subject", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
from := s.smtpUser
|
||||
if from == "" {
|
||||
from = "noreply@" + s.smtpHost
|
||||
}
|
||||
|
||||
emailContent := s.buildEmailContent(to, subject, body, from)
|
||||
isAuthRequired := s.smtpUser != "" && s.smtpPassword != ""
|
||||
|
||||
if s.smtpPort == ImplicitTLSPort {
|
||||
return s.sendImplicitTLS(to, from, emailContent, isAuthRequired)
|
||||
}
|
||||
|
||||
return s.sendStartTLS(to, from, emailContent, isAuthRequired)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) buildEmailContent(to, subject, body, from string) []byte {
|
||||
// Encode Subject header using RFC 2047 to avoid SMTPUTF8 requirement
|
||||
encodedSubject := encodeRFC2047(subject)
|
||||
subjectHeader := fmt.Sprintf("Subject: %s\r\n", encodedSubject)
|
||||
dateHeader := fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
|
||||
mimeHeaders := fmt.Sprintf(
|
||||
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
|
||||
MIMETypeHTML,
|
||||
MIMECharsetUTF8,
|
||||
)
|
||||
|
||||
// Encode From header display name if it contains non-ASCII
|
||||
encodedFrom := encodeRFC2047(from)
|
||||
fromHeader := fmt.Sprintf("From: %s\r\n", encodedFrom)
|
||||
|
||||
toHeader := fmt.Sprintf("To: %s\r\n", to)
|
||||
|
||||
return []byte(fromHeader + toHeader + subjectHeader + dateHeader + mimeHeaders + body)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) sendImplicitTLS(
|
||||
to, from string,
|
||||
emailContent []byte,
|
||||
isAuthRequired bool,
|
||||
) error {
|
||||
createClient := func() (*smtp.Client, func(), error) {
|
||||
return s.createImplicitTLSClient()
|
||||
}
|
||||
|
||||
client, cleanup, err := s.authenticateWithRetry(createClient, isAuthRequired)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return s.sendEmail(client, to, from, emailContent)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) sendStartTLS(
|
||||
to, from string,
|
||||
emailContent []byte,
|
||||
isAuthRequired bool,
|
||||
) error {
|
||||
createClient := func() (*smtp.Client, func(), error) {
|
||||
return s.createStartTLSClient()
|
||||
}
|
||||
|
||||
client, cleanup, err := s.authenticateWithRetry(createClient, isAuthRequired)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return s.sendEmail(client, to, from, emailContent)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) createImplicitTLSClient() (*smtp.Client, func(), error) {
|
||||
addr := net.JoinHostPort(s.smtpHost, fmt.Sprintf("%d", s.smtpPort))
|
||||
tlsConfig := &tls.Config{ServerName: s.smtpHost}
|
||||
dialer := &net.Dialer{Timeout: DefaultTimeout}
|
||||
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, s.smtpHost)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
|
||||
return client, func() { _ = client.Quit() }, nil
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) createStartTLSClient() (*smtp.Client, func(), error) {
|
||||
addr := net.JoinHostPort(s.smtpHost, fmt.Sprintf("%d", s.smtpPort))
|
||||
dialer := &net.Dialer{Timeout: DefaultTimeout}
|
||||
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, s.smtpHost)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Hello(DefaultHelloName); err != nil {
|
||||
_ = client.Quit()
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("SMTP hello failed: %w", err)
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: s.smtpHost}); err != nil {
|
||||
_ = client.Quit()
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("STARTTLS failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return client, func() { _ = client.Quit() }, nil
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) authenticateWithRetry(
|
||||
createClient func() (*smtp.Client, func(), error),
|
||||
isAuthRequired bool,
|
||||
) (*smtp.Client, func(), error) {
|
||||
client, cleanup, err := createClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !isAuthRequired {
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
// Try PLAIN auth first
|
||||
plainAuth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
|
||||
if err := client.Auth(plainAuth); err == nil {
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
// PLAIN auth failed, connection may be closed - recreate and try LOGIN auth
|
||||
cleanup()
|
||||
|
||||
client, cleanup, err = createClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
loginAuth := &loginAuth{username: s.smtpUser, password: s.smtpPassword}
|
||||
if err := client.Auth(loginAuth); err != nil {
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) sendEmail(client *smtp.Client, to, from string, content []byte) error {
|
||||
if err := client.Mail(from); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
|
||||
if _, err = writer.Write(content); err != nil {
|
||||
return fmt.Errorf("failed to write email content: %w", err)
|
||||
}
|
||||
|
||||
if err = writer.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeRFC2047(s string) string {
|
||||
return mime.QEncoding.Encode("UTF-8", s)
|
||||
}
|
||||
|
||||
type loginAuth struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:", "User Name\x00":
|
||||
return []byte(a.username), nil
|
||||
case "Password:", "Password\x00":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return []byte(a.username), nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -144,6 +144,10 @@ func Test_GetAttemptsByDatabase_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
assert.Contains(t, string(testResp.Body), "forbidden")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -181,6 +185,10 @@ func Test_GetAttemptsByDatabase_FiltersByAfterDate(t *testing.T) {
|
||||
for _, attempt := range response {
|
||||
assert.True(t, attempt.CreatedAt.After(afterDate) || attempt.CreatedAt.Equal(afterDate))
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_GetAttemptsByDatabase_ReturnsEmptyListForNewDatabase(t *testing.T) {
|
||||
@@ -201,6 +209,10 @@ func Test_GetAttemptsByDatabase_ReturnsEmptyListForNewDatabase(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Equal(t, 0, len(response))
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func createTestDatabaseViaAPI(
|
||||
|
||||
@@ -130,6 +130,10 @@ func Test_SaveHealthcheckConfig_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -162,6 +166,10 @@ func Test_SaveHealthcheckConfig_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t
|
||||
)
|
||||
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_GetHealthcheckConfig_PermissionsEnforced(t *testing.T) {
|
||||
@@ -268,6 +276,10 @@ func Test_GetHealthcheckConfig_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -295,6 +307,10 @@ func Test_GetHealthcheckConfig_ReturnsDefaultConfigForNewDatabase(t *testing.T)
|
||||
assert.Equal(t, 1, response.IntervalMinutes)
|
||||
assert.Equal(t, 3, response.AttemptsBeforeConcideredAsDown)
|
||||
assert.Equal(t, 7, response.StoreAttemptsDays)
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func createTestDatabaseViaAPI(
|
||||
|
||||
@@ -130,6 +130,7 @@ func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte
|
||||
// This ensures compatibility with SMTP servers that don't support SMTPUTF8
|
||||
encodedSubject := encodeRFC2047(heading)
|
||||
subject := fmt.Sprintf("Subject: %s\r\n", encodedSubject)
|
||||
dateHeader := fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
|
||||
mimeHeaders := fmt.Sprintf(
|
||||
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
|
||||
@@ -143,7 +144,7 @@ func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte
|
||||
|
||||
toHeader := fmt.Sprintf("To: %s\r\n", e.TargetEmail)
|
||||
|
||||
return []byte(fromHeader + toHeader + subject + mimeHeaders + message)
|
||||
return []byte(fromHeader + toHeader + subject + dateHeader + mimeHeaders + message)
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) sendImplicitTLS(
|
||||
|
||||
@@ -46,8 +46,10 @@ func Test_GetRestores_WhenUserIsWorkspaceMember_RestoresReturned(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
var restores []*restores_core.Restore
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
@@ -68,8 +70,10 @@ func Test_GetRestores_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testing.T
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
@@ -88,8 +92,10 @@ func Test_GetRestores_WhenUserIsGlobalAdmin_RestoresReturned(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
|
||||
@@ -114,8 +120,10 @@ func Test_RestoreBackup_WhenUserIsWorkspaceMember_RestoreInitiated(t *testing.T)
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
request := restores_core.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &postgresql.PostgresqlDatabase{
|
||||
@@ -143,8 +151,10 @@ func Test_RestoreBackup_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testing
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
@@ -178,8 +188,10 @@ func Test_RestoreBackup_WithIsExcludeExtensions_FlagPassedCorrectly(t *testing.T
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
request := restores_core.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &postgresql.PostgresqlDatabase{
|
||||
@@ -212,8 +224,10 @@ func Test_RestoreBackup_AuditLogWritten(t *testing.T) {
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
request := restores_core.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &postgresql.PostgresqlDatabase{
|
||||
@@ -296,12 +310,16 @@ func Test_RestoreBackup_DiskSpaceValidation(t *testing.T) {
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var database *databases.Database
|
||||
var backup *backups_core.Backup
|
||||
var storage *storages.Storage
|
||||
var request restores_core.RestoreBackupRequest
|
||||
|
||||
if tc.dbType == databases.DatabaseTypePostgres {
|
||||
_, backup = createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup = createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
request = restores_core.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
@@ -319,7 +337,16 @@ func Test_RestoreBackup_DiskSpaceValidation(t *testing.T) {
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
database = mysqlDB
|
||||
storage = createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
// Cleanup in dependency order: backup -> database -> storage
|
||||
cleanupBackup(backup)
|
||||
databases.RemoveTestDatabase(mysqlDB)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
}()
|
||||
|
||||
configService := backups_config.GetBackupConfigService()
|
||||
config, err := configService.GetBackupConfigByDbId(mysqlDB.ID)
|
||||
@@ -332,6 +359,7 @@ func Test_RestoreBackup_DiskSpaceValidation(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
backup = createTestBackup(mysqlDB, owner)
|
||||
|
||||
request = restores_core.RestoreBackupRequest{
|
||||
MysqlDatabase: &mysql.MysqlDatabase{
|
||||
Version: tools.MysqlVersion80,
|
||||
@@ -519,8 +547,10 @@ func Test_RestoreBackup_WithParallelRestoreInProgress_ReturnsError(t *testing.T)
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
request := restores_core.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &postgresql.PostgresqlDatabase{
|
||||
@@ -741,3 +771,22 @@ func createTestBackup(
|
||||
|
||||
return backup
|
||||
}
|
||||
|
||||
func cleanupDatabaseWithBackup(database *databases.Database, backup *backups_core.Backup) {
|
||||
// Clean up in reverse dependency order
|
||||
cleanupBackup(backup)
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Clean up storage last (after database and backup are removed)
|
||||
configService := backups_config.GetBackupConfigService()
|
||||
config, err := configService.GetBackupConfigByDbId(database.ID)
|
||||
if err == nil && config.StorageID != nil {
|
||||
storages.RemoveTestStorage(*config.StorageID)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupBackup(backup *backups_core.Backup) {
|
||||
repo := &backups_core.BackupRepository{}
|
||||
repo.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ func (c *StorageController) SaveStorage(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.storageService.SaveStorage(user, request.WorkspaceID, &request); err != nil {
|
||||
if errors.Is(err, ErrInsufficientPermissionsToManageStorage) {
|
||||
if errors.Is(err, ErrInsufficientPermissionsToManageStorage) ||
|
||||
errors.Is(err, ErrLocalStorageNotAllowedInCloudMode) {
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -325,7 +326,11 @@ func (c *StorageController) TestStorageConnectionDirect(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.storageService.TestStorageConnectionDirect(&request); err != nil {
|
||||
if err := c.storageService.TestStorageConnectionDirect(user, &request); err != nil {
|
||||
if errors.Is(err, ErrLocalStorageNotAllowedInCloudMode) {
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1653,6 +1653,10 @@ func Test_StorageSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
&finalRetrieved,
|
||||
)
|
||||
tc.verifyHiddenData(t, &finalRetrieved)
|
||||
|
||||
// Cleanup
|
||||
deleteStorage(t, router, createdStorage.ID, owner.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,7 @@ var (
|
||||
ErrSystemStorageCannotBeMadePrivate = errors.New(
|
||||
"system storage cannot be changed to non-system",
|
||||
)
|
||||
ErrLocalStorageNotAllowedInCloudMode = errors.New(
|
||||
"local storage can only be managed by administrators in cloud mode",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package storages
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"databasus-backend/internal/config"
|
||||
audit_logs "databasus-backend/internal/features/audit_logs"
|
||||
users_enums "databasus-backend/internal/features/users/enums"
|
||||
users_models "databasus-backend/internal/features/users/models"
|
||||
@@ -37,6 +38,11 @@ func (s *StorageService) SaveStorage(
|
||||
return ErrInsufficientPermissionsToManageStorage
|
||||
}
|
||||
|
||||
if config.GetEnv().IsCloud && storage.Type == StorageTypeLocal &&
|
||||
user.Role != users_enums.UserRoleAdmin {
|
||||
return ErrLocalStorageNotAllowedInCloudMode
|
||||
}
|
||||
|
||||
isUpdate := storage.ID != uuid.Nil
|
||||
|
||||
if storage.IsSystem && user.Role != users_enums.UserRoleAdmin {
|
||||
@@ -238,8 +244,14 @@ func (s *StorageService) TestStorageConnection(
|
||||
}
|
||||
|
||||
func (s *StorageService) TestStorageConnectionDirect(
|
||||
user *users_models.User,
|
||||
storage *Storage,
|
||||
) error {
|
||||
if config.GetEnv().IsCloud && storage.Type == StorageTypeLocal &&
|
||||
user.Role != users_enums.UserRoleAdmin {
|
||||
return ErrLocalStorageNotAllowedInCloudMode
|
||||
}
|
||||
|
||||
var usingStorage *Storage
|
||||
|
||||
if storage.ID != uuid.Nil {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package system_healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"databasus-backend/internal/config"
|
||||
"databasus-backend/internal/features/backups/backups/backuping"
|
||||
"databasus-backend/internal/features/disk"
|
||||
"databasus-backend/internal/storage"
|
||||
cache_utils "databasus-backend/internal/util/cache"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HealthcheckService struct {
|
||||
@@ -15,6 +18,20 @@ type HealthcheckService struct {
|
||||
}
|
||||
|
||||
func (s *HealthcheckService) IsHealthy() error {
|
||||
return s.performHealthCheck()
|
||||
}
|
||||
|
||||
func (s *HealthcheckService) performHealthCheck() error {
|
||||
// Check if cache is available with PING
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := cache_utils.GetValkeyClient()
|
||||
pingResult := client.Do(ctx, client.B().Ping().Build())
|
||||
if pingResult.Error() != nil {
|
||||
return errors.New("cannot connect to valkey")
|
||||
}
|
||||
|
||||
diskUsage, err := s.diskService.GetDiskUsage()
|
||||
if err != nil {
|
||||
return errors.New("cannot get disk usage")
|
||||
@@ -40,6 +57,7 @@ func (s *HealthcheckService) IsHealthy() error {
|
||||
if config.GetEnv().IsProcessingNode {
|
||||
if !s.backuperNode.IsBackuperRunning() {
|
||||
return errors.New("backuper node is not running for more than 5 minutes")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
package users_controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
users_dto "databasus-backend/internal/features/users/dto"
|
||||
users_enums "databasus-backend/internal/features/users/enums"
|
||||
users_models "databasus-backend/internal/features/users/models"
|
||||
users_services "databasus-backend/internal/features/users/services"
|
||||
users_testing "databasus-backend/internal/features/users/testing"
|
||||
"databasus-backend/internal/storage"
|
||||
test_utils "databasus-backend/internal/util/testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func Test_SendResetPasswordCode_WithValidEmail_CodeSent(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
|
||||
assert.Equal(t, user.Email, mockEmailSender.SentEmails[0].To)
|
||||
assert.Contains(t, mockEmailSender.SentEmails[0].Subject, "Password Reset")
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithNonExistentUser_ReturnsSuccess(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: "nonexistent" + uuid.New().String() + "@example.com",
|
||||
}
|
||||
|
||||
// Should return success to prevent enumeration attacks
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// But no email should be sent
|
||||
assert.Equal(t, 0, len(mockEmailSender.SentEmails))
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithInvitedUser_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
email := "invited" + uuid.New().String() + "@example.com"
|
||||
|
||||
inviteRequest := users_dto.InviteUserRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/invite",
|
||||
"Bearer "+adminUser.Token,
|
||||
inviteRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "only active users")
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithRateLimitExceeded_ReturnsTooManyRequests(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
// Make 3 requests (should succeed)
|
||||
for range 3 {
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
// 4th request should be rate limited
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusTooManyRequests,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "Rate limit exceeded")
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithInvalidJSON_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
|
||||
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
|
||||
Method: "POST",
|
||||
URL: "/api/v1/users/send-reset-password-code",
|
||||
Body: "invalid json",
|
||||
ExpectedStatus: http.StatusBadRequest,
|
||||
})
|
||||
|
||||
assert.Contains(t, string(resp.Body), "Invalid request format")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithValidCode_PasswordReset(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
email := "resettest" + uuid.New().String() + "@example.com"
|
||||
oldPassword := "oldpassword123"
|
||||
newPassword := "newpassword456"
|
||||
|
||||
// Create user
|
||||
signupRequest := users_dto.SignUpRequestDTO{
|
||||
Email: email,
|
||||
Password: oldPassword,
|
||||
Name: "Test User",
|
||||
}
|
||||
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
|
||||
|
||||
// Request reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// Extract code from email
|
||||
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
|
||||
emailBody := mockEmailSender.SentEmails[0].Body
|
||||
code := extractCodeFromEmail(emailBody)
|
||||
t.Logf("Extracted code: %s from email body (length: %d)", code, len(code))
|
||||
assert.NotEmpty(t, code, "Code should be extracted from email")
|
||||
assert.Len(t, code, 6, "Code should be 6 digits")
|
||||
|
||||
// Reset password
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: newPassword,
|
||||
}
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Logf("Reset password failed with body: %s", string(resp.Body))
|
||||
}
|
||||
|
||||
// Verify old password doesn't work
|
||||
oldSigninRequest := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: oldPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
oldSigninRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
// Verify new password works
|
||||
newSigninRequest := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: newPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
newSigninRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithExpiredCode_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
// Create expired reset code directly in database
|
||||
code := "123456"
|
||||
hashedCode, _ := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
expiredCode := &users_models.PasswordResetCode{
|
||||
ID: uuid.New(),
|
||||
UserID: user.UserID,
|
||||
HashedCode: string(hashedCode),
|
||||
ExpiresAt: time.Now().UTC().Add(-1 * time.Hour), // Expired 1 hour ago
|
||||
IsUsed: false,
|
||||
CreatedAt: time.Now().UTC().Add(-2 * time.Hour),
|
||||
}
|
||||
storage.GetDb().Create(expiredCode)
|
||||
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: user.Email,
|
||||
Code: code,
|
||||
NewPassword: "newpassword123",
|
||||
}
|
||||
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "invalid or expired")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithUsedCode_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
email := "usedcode" + uuid.New().String() + "@example.com"
|
||||
|
||||
// Create user
|
||||
signupRequest := users_dto.SignUpRequestDTO{
|
||||
Email: email,
|
||||
Password: "password123",
|
||||
Name: "Test User",
|
||||
}
|
||||
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
|
||||
|
||||
// Request reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
code := extractCodeFromEmail(mockEmailSender.SentEmails[0].Body)
|
||||
|
||||
// Use code first time
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: "newpassword123",
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// Try to use same code again
|
||||
resetRequest2 := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: "anotherpassword456",
|
||||
}
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest2,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "invalid or expired")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithWrongCode_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
// Request reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// Try to reset with wrong code
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: user.Email,
|
||||
Code: "999999", // Wrong code
|
||||
NewPassword: "newpassword123",
|
||||
}
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "invalid")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithInvalidNewPassword_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: user.Email,
|
||||
Code: "123456",
|
||||
NewPassword: "short", // Too short
|
||||
}
|
||||
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
|
||||
func Test_ResetPassword_EmailSendFailure_ReturnsError(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
mockEmailSender.ShouldFail = true
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "failed to send email")
|
||||
}
|
||||
|
||||
func Test_ResetPasswordFlow_E2E_CompletesSuccessfully(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
email := "e2e" + uuid.New().String() + "@example.com"
|
||||
initialPassword := "initialpass123"
|
||||
newPassword := "brandnewpass456"
|
||||
|
||||
// 1. Create user via signup
|
||||
signupRequest := users_dto.SignUpRequestDTO{
|
||||
Email: email,
|
||||
Password: initialPassword,
|
||||
Name: "E2E Test User",
|
||||
}
|
||||
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
|
||||
|
||||
// 2. Verify can sign in with initial password
|
||||
signinRequest := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: initialPassword,
|
||||
}
|
||||
var signinResponse users_dto.SignInResponseDTO
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
signinRequest,
|
||||
http.StatusOK,
|
||||
&signinResponse,
|
||||
)
|
||||
assert.NotEmpty(t, signinResponse.Token)
|
||||
|
||||
// 3. Request password reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// 4. Verify email was sent
|
||||
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
|
||||
code := extractCodeFromEmail(mockEmailSender.SentEmails[0].Body)
|
||||
assert.NotEmpty(t, code)
|
||||
|
||||
// 5. Reset password using code
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: newPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// 6. Verify old password no longer works
|
||||
oldSignin := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: initialPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
oldSignin,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
// 7. Verify new password works
|
||||
newSignin := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: newPassword,
|
||||
}
|
||||
var finalResponse users_dto.SignInResponseDTO
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
newSignin,
|
||||
http.StatusOK,
|
||||
&finalResponse,
|
||||
)
|
||||
assert.NotEmpty(t, finalResponse.Token)
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithInvalidJSON_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
|
||||
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
|
||||
Method: "POST",
|
||||
URL: "/api/v1/users/reset-password",
|
||||
Body: "invalid json",
|
||||
ExpectedStatus: http.StatusBadRequest,
|
||||
})
|
||||
|
||||
assert.Contains(t, string(resp.Body), "Invalid request format")
|
||||
}
|
||||
|
||||
// Helper function to extract 6-digit code from email HTML body
|
||||
func extractCodeFromEmail(emailBody string) string {
|
||||
// Look for pattern: <h1 ... >CODE</h1>
|
||||
// First find <h1
|
||||
h1Start := 0
|
||||
for i := 0; i < len(emailBody)-3; i++ {
|
||||
if emailBody[i:i+3] == "<h1" {
|
||||
h1Start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if h1Start == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Find the > after <h1
|
||||
contentStart := h1Start
|
||||
for i := h1Start; i < len(emailBody); i++ {
|
||||
if emailBody[i] == '>' {
|
||||
contentStart = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Find </h1>
|
||||
contentEnd := contentStart
|
||||
for i := contentStart; i < len(emailBody)-5; i++ {
|
||||
if emailBody[i:i+5] == "</h1>" {
|
||||
contentEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if contentEnd <= contentStart {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract content and remove whitespace
|
||||
content := emailBody[contentStart:contentEnd]
|
||||
code := ""
|
||||
for i := 0; i < len(content); i++ {
|
||||
if isDigit(content[i]) {
|
||||
code += string(content[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(code) == 6 {
|
||||
return code
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDigit(b byte) bool {
|
||||
return b >= '0' && b <= '9'
|
||||
}
|
||||
@@ -28,6 +28,10 @@ func (c *UserController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/users/admin/has-password", c.IsAdminHasPassword)
|
||||
router.POST("/users/admin/set-password", c.SetAdminPassword)
|
||||
|
||||
// Password reset (no auth required)
|
||||
router.POST("/users/send-reset-password-code", c.SendResetPasswordCode)
|
||||
router.POST("/users/reset-password", c.ResetPassword)
|
||||
|
||||
// OAuth callbacks
|
||||
router.POST("/auth/github/callback", c.HandleGitHubOAuth)
|
||||
router.POST("/auth/google/callback", c.HandleGoogleOAuth)
|
||||
@@ -340,3 +344,70 @@ func (c *UserController) HandleGoogleOAuth(ctx *gin.Context) {
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// SendResetPasswordCode
|
||||
// @Summary Send password reset code
|
||||
// @Description Send a password reset code to the user's email
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body users_dto.SendResetPasswordCodeRequestDTO true "Email address"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 429 {object} map[string]string
|
||||
// @Router /users/send-reset-password-code [post]
|
||||
func (c *UserController) SendResetPasswordCode(ctx *gin.Context) {
|
||||
var request user_dto.SendResetPasswordCodeRequestDTO
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
return
|
||||
}
|
||||
|
||||
allowed, _ := c.rateLimiter.CheckLimit(
|
||||
request.Email,
|
||||
"reset-password",
|
||||
3,
|
||||
1*time.Hour,
|
||||
)
|
||||
if !allowed {
|
||||
ctx.JSON(
|
||||
http.StatusTooManyRequests,
|
||||
gin.H{"error": "Rate limit exceeded. Please try again later."},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
err := c.userService.SendResetPasswordCode(request.Email)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"message": "If the email exists, a reset code has been sent"})
|
||||
}
|
||||
|
||||
// ResetPassword
|
||||
// @Summary Reset password with code
|
||||
// @Description Reset user password using the code sent via email
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body users_dto.ResetPasswordRequestDTO true "Reset password data"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Router /users/reset-password [post]
|
||||
func (c *UserController) ResetPassword(ctx *gin.Context) {
|
||||
var request user_dto.ResetPasswordRequestDTO
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
return
|
||||
}
|
||||
|
||||
err := c.userService.ResetPassword(request.Email, request.Code, request.NewPassword)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
|
||||
}
|
||||
|
||||
@@ -92,3 +92,13 @@ type OAuthCallbackResponseDTO struct {
|
||||
Token string `json:"token"`
|
||||
IsNewUser bool `json:"isNewUser"`
|
||||
}
|
||||
|
||||
type SendResetPasswordCodeRequestDTO struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequestDTO struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
@@ -7,3 +7,7 @@ import (
|
||||
type AuditLogWriter interface {
|
||||
WriteAuditLog(message string, userID *uuid.UUID, workspaceID *uuid.UUID)
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendEmail(to, subject, body string) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package users_models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PasswordResetCode struct {
|
||||
ID uuid.UUID `json:"id" gorm:"column:id"`
|
||||
UserID uuid.UUID `json:"userId" gorm:"column:user_id"`
|
||||
HashedCode string `json:"-" gorm:"column:hashed_code"`
|
||||
ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at"`
|
||||
IsUsed bool `json:"isUsed" gorm:"column:is_used"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func (PasswordResetCode) TableName() string {
|
||||
return "password_reset_codes"
|
||||
}
|
||||
|
||||
func (p *PasswordResetCode) IsValid() bool {
|
||||
return !p.IsUsed && time.Now().UTC().Before(p.ExpiresAt)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package users_repositories
|
||||
|
||||
var userRepository = &UserRepository{}
|
||||
var usersSettingsRepository = &UsersSettingsRepository{}
|
||||
var passwordResetRepository = &PasswordResetRepository{}
|
||||
|
||||
func GetUserRepository() *UserRepository {
|
||||
return userRepository
|
||||
@@ -10,3 +11,7 @@ func GetUserRepository() *UserRepository {
|
||||
func GetUsersSettingsRepository() *UsersSettingsRepository {
|
||||
return usersSettingsRepository
|
||||
}
|
||||
|
||||
func GetPasswordResetRepository() *PasswordResetRepository {
|
||||
return passwordResetRepository
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package users_repositories
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
users_models "databasus-backend/internal/features/users/models"
|
||||
"databasus-backend/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PasswordResetRepository struct{}
|
||||
|
||||
func (r *PasswordResetRepository) CreateResetCode(code *users_models.PasswordResetCode) error {
|
||||
if code.ID == uuid.Nil {
|
||||
code.ID = uuid.New()
|
||||
}
|
||||
|
||||
return storage.GetDb().Create(code).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) GetValidCodeByUserID(
|
||||
userID uuid.UUID,
|
||||
) (*users_models.PasswordResetCode, error) {
|
||||
var code users_models.PasswordResetCode
|
||||
err := storage.GetDb().
|
||||
Where("user_id = ? AND is_used = ? AND expires_at > ?", userID, false, time.Now().UTC()).
|
||||
Order("created_at DESC").
|
||||
First(&code).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &code, nil
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) MarkCodeAsUsed(codeID uuid.UUID) error {
|
||||
return storage.GetDb().Model(&users_models.PasswordResetCode{}).
|
||||
Where("id = ?", codeID).
|
||||
Update("is_used", true).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) DeleteExpiredCodes() error {
|
||||
return storage.GetDb().
|
||||
Where("expires_at < ?", time.Now().UTC()).
|
||||
Delete(&users_models.PasswordResetCode{}).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) CountRecentCodesByUserID(
|
||||
userID uuid.UUID,
|
||||
since time.Time,
|
||||
) (int64, error) {
|
||||
var count int64
|
||||
|
||||
err := storage.GetDb().Model(&users_models.PasswordResetCode{}).
|
||||
Where("user_id = ? AND created_at > ?", userID, since).
|
||||
Count(&count).Error
|
||||
|
||||
return count, err
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package users_services
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/features/email"
|
||||
"databasus-backend/internal/features/encryption/secrets"
|
||||
users_repositories "databasus-backend/internal/features/users/repositories"
|
||||
)
|
||||
@@ -10,6 +11,8 @@ var userService = &UserService{
|
||||
secrets.GetSecretKeyService(),
|
||||
settingsService,
|
||||
nil,
|
||||
email.GetEmailSMTPSender(),
|
||||
users_repositories.GetPasswordResetRepository(),
|
||||
}
|
||||
var settingsService = &SettingsService{
|
||||
users_repositories.GetUsersSettingsRepository(),
|
||||
|
||||
@@ -2,6 +2,7 @@ package users_services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -27,16 +28,22 @@ import (
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
userRepository *users_repositories.UserRepository
|
||||
secretKeyService *secrets.SecretKeyService
|
||||
settingsService *SettingsService
|
||||
auditLogWriter users_interfaces.AuditLogWriter
|
||||
userRepository *users_repositories.UserRepository
|
||||
secretKeyService *secrets.SecretKeyService
|
||||
settingsService *SettingsService
|
||||
auditLogWriter users_interfaces.AuditLogWriter
|
||||
emailSender users_interfaces.EmailSender
|
||||
passwordResetRepository *users_repositories.PasswordResetRepository
|
||||
}
|
||||
|
||||
func (s *UserService) SetAuditLogWriter(writer users_interfaces.AuditLogWriter) {
|
||||
s.auditLogWriter = writer
|
||||
}
|
||||
|
||||
func (s *UserService) SetEmailSender(sender users_interfaces.EmailSender) {
|
||||
s.emailSender = sender
|
||||
}
|
||||
|
||||
func (s *UserService) SignUp(request *users_dto.SignUpRequestDTO) error {
|
||||
existingUser, err := s.userRepository.GetUserByEmail(request.Email)
|
||||
if err != nil {
|
||||
@@ -798,3 +805,164 @@ func (s *UserService) fetchGitHubPrimaryEmail(
|
||||
|
||||
return "", errors.New("github account has no accessible email")
|
||||
}
|
||||
|
||||
func (s *UserService) SendResetPasswordCode(email string) error {
|
||||
user, err := s.userRepository.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
// Silently succeed for non-existent users to prevent enumeration attacks
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only active users can reset passwords
|
||||
if user.Status != users_enums.UserStatusActive {
|
||||
return errors.New("only active users can reset their password")
|
||||
}
|
||||
|
||||
// Check rate limiting - max 3 codes per hour
|
||||
oneHourAgo := time.Now().UTC().Add(-1 * time.Hour)
|
||||
recentCount, err := s.passwordResetRepository.CountRecentCodesByUserID(user.ID, oneHourAgo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check rate limit: %w", err)
|
||||
}
|
||||
|
||||
if recentCount >= 3 {
|
||||
return errors.New("too many password reset attempts, please try again later")
|
||||
}
|
||||
|
||||
// Generate 6-digit random code using crypto/rand for better randomness
|
||||
codeNum := make([]byte, 4)
|
||||
_, err = io.ReadFull(rand.Reader, codeNum)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate random code: %w", err)
|
||||
}
|
||||
|
||||
// Convert bytes to uint32 and modulo to get 6 digits
|
||||
randomInt := uint32(
|
||||
codeNum[0],
|
||||
)<<24 | uint32(
|
||||
codeNum[1],
|
||||
)<<16 | uint32(
|
||||
codeNum[2],
|
||||
)<<8 | uint32(
|
||||
codeNum[3],
|
||||
)
|
||||
code := fmt.Sprintf("%06d", randomInt%1000000)
|
||||
|
||||
// Hash the code
|
||||
hashedCode, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash code: %w", err)
|
||||
}
|
||||
|
||||
// Store in database with 1 hour expiration
|
||||
resetCode := &users_models.PasswordResetCode{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
HashedCode: string(hashedCode),
|
||||
ExpiresAt: time.Now().UTC().Add(1 * time.Hour),
|
||||
IsUsed: false,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := s.passwordResetRepository.CreateResetCode(resetCode); err != nil {
|
||||
return fmt.Errorf("failed to create reset code: %w", err)
|
||||
}
|
||||
|
||||
// Send email with code
|
||||
if s.emailSender != nil {
|
||||
subject := "Password Reset Code"
|
||||
body := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 20px;">
|
||||
<h2 style="color: #333333; margin-bottom: 20px;">Password Reset Request</h2>
|
||||
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
|
||||
You have requested to reset your password. Please use the following code to complete the password reset process:
|
||||
</p>
|
||||
<div style="background-color: #f8f9fa; border: 2px solid #e9ecef; border-radius: 8px; padding: 20px; text-align: center; margin: 30px 0;">
|
||||
<h1 style="color: #2c3e50; font-size: 36px; margin: 0; letter-spacing: 8px; font-family: monospace;">%s</h1>
|
||||
</div>
|
||||
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
|
||||
This code will expire in <strong>1 hour</strong>.
|
||||
</p>
|
||||
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
|
||||
If you did not request a password reset, please ignore this email. Your password will remain unchanged.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e9ecef; margin: 30px 0;">
|
||||
<p style="color: #999999; font-size: 12px; line-height: 1.6;">
|
||||
This is an automated message. Please do not reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, code)
|
||||
|
||||
if err := s.emailSender.SendEmail(user.Email, subject, body); err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
if s.auditLogWriter != nil {
|
||||
s.auditLogWriter.WriteAuditLog(
|
||||
fmt.Sprintf("Password reset code sent to: %s", user.Email),
|
||||
&user.ID,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) ResetPassword(email, code, newPassword string) error {
|
||||
user, err := s.userRepository.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return errors.New("user with this email does not exist")
|
||||
}
|
||||
|
||||
// Get valid reset code for user
|
||||
resetCode, err := s.passwordResetRepository.GetValidCodeByUserID(user.ID)
|
||||
if err != nil {
|
||||
return errors.New("invalid or expired reset code")
|
||||
}
|
||||
|
||||
// Verify code matches
|
||||
err = bcrypt.CompareHashAndPassword([]byte(resetCode.HashedCode), []byte(code))
|
||||
if err != nil {
|
||||
return errors.New("invalid reset code")
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
if err := s.passwordResetRepository.MarkCodeAsUsed(resetCode.ID); err != nil {
|
||||
return fmt.Errorf("failed to mark code as used: %w", err)
|
||||
}
|
||||
|
||||
// Update user password
|
||||
if err := s.ChangeUserPassword(user.ID, newPassword); err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
if s.auditLogWriter != nil {
|
||||
s.auditLogWriter.WriteAuditLog(
|
||||
"Password reset via email code",
|
||||
&user.ID,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
33
backend/internal/features/users/testing/mocks.go
Normal file
33
backend/internal/features/users/testing/mocks.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package users_testing
|
||||
|
||||
import "errors"
|
||||
|
||||
type MockEmailSender struct {
|
||||
SentEmails []EmailCall
|
||||
ShouldFail bool
|
||||
}
|
||||
|
||||
type EmailCall struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
|
||||
m.SentEmails = append(m.SentEmails, EmailCall{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
})
|
||||
if m.ShouldFail {
|
||||
return errors.New("mock email send failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewMockEmailSender() *MockEmailSender {
|
||||
return &MockEmailSender{
|
||||
SentEmails: []EmailCall{},
|
||||
ShouldFail: false,
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,8 @@ func Test_GetWorkspaceMembers_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -210,6 +212,8 @@ func Test_AddMemberToWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -270,6 +274,8 @@ func Test_AddMemberToWorkspace_WhenUserIsAlreadyMember_ReturnsBadRequest(t *test
|
||||
member := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
member,
|
||||
@@ -302,6 +308,7 @@ func Test_AddMemberToWorkspace_WithNonExistentUser_ReturnsInvited(t *testing.T)
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
request := workspaces_dto.AddMemberRequestDTO{
|
||||
Email: uuid.New().String() + "@example.com", // Non-existent user
|
||||
@@ -332,6 +339,8 @@ func Test_AddMemberToWorkspace_WhenWorkspaceAdminTriesToAddAdmin_ReturnsBadReque
|
||||
newMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
workspaceAdmin,
|
||||
@@ -368,6 +377,8 @@ func Test_AddMemberToWorkspace_WhenWorkspaceAdminTriesToAddWorkspaceAdmin_Return
|
||||
newMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
workspaceAdmin,
|
||||
@@ -450,6 +461,8 @@ func Test_AddWorkspaceAdmin_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -561,6 +574,7 @@ func Test_InviteMemberToWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -672,6 +686,7 @@ func Test_ChangeMemberRole_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
@@ -774,6 +789,7 @@ func Test_ChangeMemberRoleToAdmin_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
@@ -832,6 +848,7 @@ func Test_ChangeMemberRole_WhenChangingOwnRole_ReturnsBadRequest(t *testing.T) {
|
||||
)
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
request := workspaces_dto.ChangeMemberRoleRequestDTO{
|
||||
Role: users_enums.WorkspaceRoleMember,
|
||||
@@ -861,6 +878,7 @@ func Test_ChangeMemberRole_WhenChangingOwnerRole_ReturnsBadRequest(t *testing.T)
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
request := workspaces_dto.ChangeMemberRoleRequestDTO{
|
||||
Role: users_enums.WorkspaceRoleMember,
|
||||
@@ -941,6 +959,7 @@ func Test_RemoveMemberFromWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
@@ -995,6 +1014,7 @@ func Test_RemoveMemberFromWorkspace_WhenRemovingOwner_ReturnsBadRequest(t *testi
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
|
||||
Method: "DELETE",
|
||||
@@ -1061,6 +1081,7 @@ func Test_RemoveWorkspaceAdmin_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
@@ -1165,6 +1186,7 @@ func Test_TransferWorkspaceOwnership_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
@@ -1225,6 +1247,7 @@ func Test_TransferWorkspaceOwnership_WhenNewOwnerIsNotMember_ReturnsBadRequest(t
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
request := workspaces_dto.TransferOwnershipRequestDTO{
|
||||
NewOwnerEmail: nonMember.Email,
|
||||
@@ -1250,6 +1273,8 @@ func Test_TransferWorkspaceOwnership_ThereIsOnlyOneOwner_OldOwnerBecomeAdmin(t *
|
||||
member := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
member,
|
||||
|
||||
@@ -91,6 +91,10 @@ func Test_CreateWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
assert.Equal(t, workspaceName, response.Name)
|
||||
assert.NotEqual(t, uuid.Nil, response.ID)
|
||||
assert.Equal(t, users_enums.WorkspaceRoleOwner, *response.UserRole)
|
||||
|
||||
// Cleanup created workspace
|
||||
workspace := &workspaces_models.Workspace{ID: response.ID}
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
} else {
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
@@ -160,11 +164,14 @@ func Test_GetUserWorkspaces_WhenUserHasWorkspaces_ReturnsWorkspacesList(t *testi
|
||||
user.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
|
||||
workspace2, _ := workspaces_testing.CreateTestWorkspaceWithToken(
|
||||
"Workspace 2",
|
||||
user.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
|
||||
var response workspaces_dto.ListWorkspacesResponseDTO
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
@@ -258,6 +265,7 @@ func Test_GetSingleWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -369,6 +377,7 @@ func Test_UpdateWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.workspaceRole == users_enums.WorkspaceRoleOwner {
|
||||
@@ -472,6 +481,10 @@ func Test_DeleteWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
// Only cleanup if the test doesn't successfully delete the workspace
|
||||
if !tt.expectSuccess {
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -526,6 +539,7 @@ func Test_GetWorkspaceAuditLogs_WhenUserIsWorkspaceAdmin_ReturnsAuditLogs(t *tes
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspace(
|
||||
workspace,
|
||||
@@ -565,11 +579,14 @@ func Test_GetWorkspaceAuditLogs_WithMultipleWorkspaces_ReturnsOnlyWorkspaceSpeci
|
||||
owner1.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
|
||||
workspace2, _ := workspaces_testing.CreateTestWorkspaceWithToken(
|
||||
workspaceName2,
|
||||
owner2.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
|
||||
updateWorkspace1 := workspaces_models.Workspace{
|
||||
Name: "Updated " + workspace1.Name,
|
||||
@@ -656,6 +673,7 @@ func Test_GetWorkspaceAuditLogs_WithDifferentUserRoles_EnforcesPermissionsCorrec
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspace(
|
||||
workspace,
|
||||
@@ -703,6 +721,7 @@ func Test_GetWorkspaceAuditLogs_WithoutAuthToken_ReturnsUnauthorized(t *testing.
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
test_utils.MakeGetRequest(t, router,
|
||||
"/api/v1/workspaces/"+workspace.ID.String()+"/audit-logs",
|
||||
|
||||
@@ -5,3 +5,7 @@ import "github.com/google/uuid"
|
||||
type WorkspaceDeletionListener interface {
|
||||
OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendEmail(to, subject, body string) error
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package workspaces_services
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/features/audit_logs"
|
||||
"databasus-backend/internal/features/email"
|
||||
users_services "databasus-backend/internal/features/users/services"
|
||||
workspaces_interfaces "databasus-backend/internal/features/workspaces/interfaces"
|
||||
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
|
||||
"databasus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var workspaceRepository = &workspaces_repositories.WorkspaceRepository{}
|
||||
@@ -26,6 +28,8 @@ var membershipService = &MembershipService{
|
||||
audit_logs.GetAuditLogService(),
|
||||
workspaceService,
|
||||
users_services.GetSettingsService(),
|
||||
email.GetEmailSMTPSender(),
|
||||
logger.GetLogger(),
|
||||
}
|
||||
|
||||
func GetWorkspaceService() *WorkspaceService {
|
||||
|
||||
@@ -2,7 +2,9 @@ package workspaces_services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"databasus-backend/internal/config"
|
||||
audit_logs "databasus-backend/internal/features/audit_logs"
|
||||
users_dto "databasus-backend/internal/features/users/dto"
|
||||
users_enums "databasus-backend/internal/features/users/enums"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
users_services "databasus-backend/internal/features/users/services"
|
||||
workspaces_dto "databasus-backend/internal/features/workspaces/dto"
|
||||
workspaces_errors "databasus-backend/internal/features/workspaces/errors"
|
||||
workspaces_interfaces "databasus-backend/internal/features/workspaces/interfaces"
|
||||
workspaces_models "databasus-backend/internal/features/workspaces/models"
|
||||
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
|
||||
|
||||
@@ -23,6 +26,8 @@ type MembershipService struct {
|
||||
auditLogService *audit_logs.AuditLogService
|
||||
workspaceService *WorkspaceService
|
||||
settingsService *users_services.SettingsService
|
||||
emailSender workspaces_interfaces.EmailSender
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *MembershipService) GetMembers(
|
||||
@@ -77,6 +82,12 @@ func (s *MembershipService) AddMember(
|
||||
return nil, workspaces_errors.ErrInsufficientPermissionsToInviteUsers
|
||||
}
|
||||
|
||||
// Get workspace details for email
|
||||
workspace, err := s.workspaceRepository.GetWorkspaceByID(workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get workspace: %w", err)
|
||||
}
|
||||
|
||||
inviteRequest := &users_dto.InviteUserRequestDTO{
|
||||
Email: request.Email,
|
||||
IntendedWorkspaceID: &workspaceID,
|
||||
@@ -88,6 +99,14 @@ func (s *MembershipService) AddMember(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Send invitation email
|
||||
subject := fmt.Sprintf("You've been invited to %s workspace", workspace.Name)
|
||||
body := s.buildInvitationEmailHTML(workspace.Name, addedBy.Name, string(request.Role))
|
||||
|
||||
if err := s.emailSender.SendEmail(request.Email, subject, body); err != nil {
|
||||
s.logger.Error("Failed to send invitation email", "email", request.Email, "error", err)
|
||||
}
|
||||
|
||||
membership := &workspaces_models.WorkspaceMembership{
|
||||
UserID: inviteResponse.ID,
|
||||
WorkspaceID: workspaceID,
|
||||
@@ -339,3 +358,48 @@ func (s *MembershipService) validateCanManageMembership(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MembershipService) buildInvitationEmailHTML(
|
||||
workspaceName, inviterName, role string,
|
||||
) string {
|
||||
env := config.GetEnv()
|
||||
signUpLink := ""
|
||||
if env.DatabasusURL != "" {
|
||||
signUpLink = fmt.Sprintf(`<p style="margin: 20px 0;">
|
||||
<a href="%s/sign-up" style="display: inline-block; padding: 12px 24px; background-color: #0d6efd; color: white; text-decoration: none; border-radius: 4px;">
|
||||
Sign up
|
||||
</a>
|
||||
</p>`, env.DatabasusURL)
|
||||
} else {
|
||||
signUpLink = `<p style="margin: 20px 0; color: #666;">
|
||||
Please visit your Databasus instance to sign up and access the workspace.
|
||||
</p>`
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background-color: #f8f9fa; border-radius: 8px; padding: 30px; margin: 20px 0;">
|
||||
<h1 style="color: #0d6efd; margin-top: 0;">Workspace Invitation</h1>
|
||||
|
||||
<p style="font-size: 16px; margin: 20px 0;">
|
||||
<strong>%s</strong> has invited you to join the <strong>%s</strong> workspace as a <strong>%s</strong>.
|
||||
</p>
|
||||
|
||||
%s
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #dee2e6; margin: 30px 0;">
|
||||
|
||||
<p style="font-size: 14px; color: #6c757d; margin: 0;">
|
||||
This is an automated message from Databasus. If you didn't expect this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, inviterName, workspaceName, role, signUpLink)
|
||||
}
|
||||
|
||||
33
backend/internal/features/workspaces/testing/mocks.go
Normal file
33
backend/internal/features/workspaces/testing/mocks.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package workspaces_testing
|
||||
|
||||
import "errors"
|
||||
|
||||
type MockEmailSender struct {
|
||||
SendEmailCalls []EmailCall
|
||||
ShouldFail bool
|
||||
}
|
||||
|
||||
type EmailCall struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
|
||||
m.SendEmailCalls = append(m.SendEmailCalls, EmailCall{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
})
|
||||
if m.ShouldFail {
|
||||
return errors.New("mock email send failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewMockEmailSender() *MockEmailSender {
|
||||
return &MockEmailSender{
|
||||
SendEmailCalls: []EmailCall{},
|
||||
ShouldFail: false,
|
||||
}
|
||||
}
|
||||
@@ -375,7 +375,13 @@ func RemoveTestWorkspace(workspace *workspaces_models.Workspace, router *gin.Eng
|
||||
membershipRepo := &workspaces_repositories.MembershipRepository{}
|
||||
workspaceMembers, err := membershipRepo.GetWorkspaceMembers(workspace.ID)
|
||||
if err != nil {
|
||||
panic("Failed to get workspace members: " + err.Error())
|
||||
// Workspace might already be deleted or doesn't exist, silently return
|
||||
return
|
||||
}
|
||||
|
||||
if len(workspaceMembers) == 0 {
|
||||
// No members found, workspace might have been deleted, silently return
|
||||
return
|
||||
}
|
||||
|
||||
var ownerToken string
|
||||
@@ -385,12 +391,16 @@ func RemoveTestWorkspace(workspace *workspaces_models.Workspace, router *gin.Eng
|
||||
|
||||
owner, err := userService.GetUserByID(m.UserID)
|
||||
if err != nil {
|
||||
panic("Failed to get owner user: " + err.Error())
|
||||
// Owner user not found, workspace might be in inconsistent state, try direct deletion
|
||||
_ = RemoveTestWorkspaceDirect(workspace.ID)
|
||||
return
|
||||
}
|
||||
|
||||
tokenResponse, err := userService.GenerateAccessToken(owner)
|
||||
if err != nil {
|
||||
panic("Failed to generate owner token: " + err.Error())
|
||||
// Cannot generate token, try direct deletion
|
||||
_ = RemoveTestWorkspaceDirect(workspace.ID)
|
||||
return
|
||||
}
|
||||
|
||||
ownerToken = tokenResponse.Token
|
||||
@@ -399,7 +409,9 @@ func RemoveTestWorkspace(workspace *workspaces_models.Workspace, router *gin.Eng
|
||||
}
|
||||
|
||||
if ownerToken == "" {
|
||||
panic("No workspace owner found")
|
||||
// No owner found, try direct deletion
|
||||
_ = RemoveTestWorkspaceDirect(workspace.ID)
|
||||
return
|
||||
}
|
||||
|
||||
DeleteWorkspace(workspace, ownerToken, router)
|
||||
|
||||
@@ -46,8 +46,8 @@ func LoadMainDb() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sqlDB.SetMaxOpenConns(20)
|
||||
sqlDB.SetMaxIdleConns(20)
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
|
||||
db = database
|
||||
|
||||
|
||||
@@ -1,48 +1,131 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
var (
|
||||
loggerInstance *slog.Logger
|
||||
once sync.Once
|
||||
loggerInstance *slog.Logger
|
||||
victoriaLogsWriter *VictoriaLogsWriter
|
||||
once sync.Once
|
||||
shutdownOnce sync.Once
|
||||
envLoadOnce sync.Once
|
||||
)
|
||||
|
||||
// GetLogger returns a singleton slog.Logger that logs to the console
|
||||
func GetLogger() *slog.Logger {
|
||||
once.Do(func() {
|
||||
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
// Create stdout handler
|
||||
stdoutHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey {
|
||||
a.Value = slog.StringValue(time.Now().Format("2006/01/02 15:04:05"))
|
||||
}
|
||||
|
||||
if a.Key == slog.MessageKey {
|
||||
// Format the message to match the desired output format
|
||||
return slog.Attr{
|
||||
Key: slog.MessageKey,
|
||||
Value: slog.StringValue(a.Value.String()),
|
||||
}
|
||||
}
|
||||
|
||||
// Remove level and other attributes to get clean output
|
||||
if a.Key == slog.LevelKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
})
|
||||
|
||||
loggerInstance = slog.New(handler)
|
||||
// Try to initialize VictoriaLogs writer if configured
|
||||
// Note: This will be called before config is fully loaded in some cases,
|
||||
// so we need to handle that gracefully
|
||||
victoriaLogsWriter = tryInitVictoriaLogs()
|
||||
|
||||
// Create multi-handler
|
||||
multiHandler := NewMultiHandler(stdoutHandler, victoriaLogsWriter)
|
||||
loggerInstance = slog.New(multiHandler)
|
||||
|
||||
loggerInstance.Info("Text structured logger initialized")
|
||||
if victoriaLogsWriter != nil {
|
||||
loggerInstance.Info("VictoriaLogs enabled")
|
||||
} else {
|
||||
loggerInstance.Info("VictoriaLogs disabled")
|
||||
}
|
||||
})
|
||||
|
||||
return loggerInstance
|
||||
}
|
||||
|
||||
// ShutdownVictoriaLogs gracefully shuts down the VictoriaLogs writer
|
||||
func ShutdownVictoriaLogs(timeout time.Duration) {
|
||||
shutdownOnce.Do(func() {
|
||||
if victoriaLogsWriter != nil {
|
||||
victoriaLogsWriter.Shutdown(timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func tryInitVictoriaLogs() *VictoriaLogsWriter {
|
||||
// Ensure .env is loaded before reading environment variables
|
||||
ensureEnvLoaded()
|
||||
|
||||
// Try to get config - this may fail early in startup
|
||||
url := getVictoriaLogsURL()
|
||||
password := getVictoriaLogsPassword()
|
||||
|
||||
if url == "" {
|
||||
fmt.Println("VictoriaLogs URL is not set")
|
||||
return nil
|
||||
}
|
||||
|
||||
return NewVictoriaLogsWriter(url, password)
|
||||
}
|
||||
|
||||
func ensureEnvLoaded() {
|
||||
envLoadOnce.Do(func() {
|
||||
// Get current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: could not get current working directory: %v\n", err)
|
||||
cwd = "."
|
||||
}
|
||||
|
||||
// Find backend root by looking for go.mod
|
||||
backendRoot := cwd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(backendRoot, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
parent := filepath.Dir(backendRoot)
|
||||
if parent == backendRoot {
|
||||
break
|
||||
}
|
||||
|
||||
backendRoot = parent
|
||||
}
|
||||
|
||||
// Try to load .env from various locations
|
||||
envPaths := []string{
|
||||
filepath.Join(cwd, ".env"),
|
||||
filepath.Join(backendRoot, ".env"),
|
||||
}
|
||||
|
||||
for _, path := range envPaths {
|
||||
if err := godotenv.Load(path); err == nil {
|
||||
fmt.Printf("Logger: loaded .env from %s\n", path)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Logger: .env file not found, using existing environment variables")
|
||||
})
|
||||
}
|
||||
|
||||
func getVictoriaLogsURL() string {
|
||||
return os.Getenv("VICTORIA_LOGS_URL")
|
||||
}
|
||||
|
||||
func getVictoriaLogsPassword() string {
|
||||
return os.Getenv("VICTORIA_LOGS_PASSWORD")
|
||||
}
|
||||
|
||||
59
backend/internal/util/logger/multi_handler.go
Normal file
59
backend/internal/util/logger/multi_handler.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type MultiHandler struct {
|
||||
stdoutHandler slog.Handler
|
||||
victoriaLogsWriter *VictoriaLogsWriter
|
||||
}
|
||||
|
||||
func NewMultiHandler(
|
||||
stdoutHandler slog.Handler,
|
||||
victoriaLogsWriter *VictoriaLogsWriter,
|
||||
) *MultiHandler {
|
||||
return &MultiHandler{
|
||||
stdoutHandler: stdoutHandler,
|
||||
victoriaLogsWriter: victoriaLogsWriter,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MultiHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.stdoutHandler.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h *MultiHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
// Send to stdout handler
|
||||
if err := h.stdoutHandler.Handle(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send to VictoriaLogs if configured
|
||||
if h.victoriaLogsWriter != nil {
|
||||
attrs := make(map[string]interface{})
|
||||
record.Attrs(func(a slog.Attr) bool {
|
||||
attrs[a.Key] = a.Value.Any()
|
||||
return true
|
||||
})
|
||||
|
||||
h.victoriaLogsWriter.Write(record.Level.String(), record.Message, attrs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &MultiHandler{
|
||||
stdoutHandler: h.stdoutHandler.WithAttrs(attrs),
|
||||
victoriaLogsWriter: h.victoriaLogsWriter,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MultiHandler) WithGroup(name string) slog.Handler {
|
||||
return &MultiHandler{
|
||||
stdoutHandler: h.stdoutHandler.WithGroup(name),
|
||||
victoriaLogsWriter: h.victoriaLogsWriter,
|
||||
}
|
||||
}
|
||||
201
backend/internal/util/logger/victorialogs_writer.go
Normal file
201
backend/internal/util/logger/victorialogs_writer.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type logEntry struct {
|
||||
Time string `json:"_time"`
|
||||
Message string `json:"_msg"`
|
||||
Level string `json:"level"`
|
||||
Attrs map[string]any `json:",inline"`
|
||||
}
|
||||
|
||||
type VictoriaLogsWriter struct {
|
||||
url string
|
||||
password string
|
||||
httpClient *http.Client
|
||||
logChannel chan logEntry
|
||||
wg sync.WaitGroup
|
||||
once sync.Once
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewVictoriaLogsWriter(url, password string) *VictoriaLogsWriter {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
writer := &VictoriaLogsWriter{
|
||||
url: url,
|
||||
password: password,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
logChannel: make(chan logEntry, 1000),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
|
||||
}
|
||||
|
||||
// Start 3 worker goroutines
|
||||
for range 3 {
|
||||
writer.wg.Add(1)
|
||||
go writer.worker()
|
||||
}
|
||||
|
||||
return writer
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) Write(level, message string, attrs map[string]interface{}) {
|
||||
entry := logEntry{
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Message: message,
|
||||
Level: level,
|
||||
Attrs: attrs,
|
||||
}
|
||||
|
||||
select {
|
||||
case w.logChannel <- entry:
|
||||
// Successfully queued
|
||||
default:
|
||||
// Channel is full, drop log with warning
|
||||
w.logger.Warn("VictoriaLogs channel buffer full, dropping log entry")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) worker() {
|
||||
defer w.wg.Done()
|
||||
|
||||
batch := make([]logEntry, 0, 100)
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
w.flushBatch(batch)
|
||||
return
|
||||
|
||||
case entry, ok := <-w.logChannel:
|
||||
if !ok {
|
||||
w.flushBatch(batch)
|
||||
return
|
||||
}
|
||||
|
||||
batch = append(batch, entry)
|
||||
|
||||
// Send batch if it reaches 100 entries
|
||||
if len(batch) >= 100 {
|
||||
w.sendBatch(batch)
|
||||
batch = make([]logEntry, 0, 100)
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
if len(batch) > 0 {
|
||||
w.sendBatch(batch)
|
||||
batch = make([]logEntry, 0, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) sendBatch(entries []logEntry) {
|
||||
backoffs := []time.Duration{0, 5 * time.Second, 30 * time.Second, 1 * time.Minute}
|
||||
|
||||
for attempt := range 4 {
|
||||
if backoffs[attempt] > 0 {
|
||||
time.Sleep(backoffs[attempt])
|
||||
}
|
||||
|
||||
if err := w.sendHTTP(entries); err == nil {
|
||||
return
|
||||
} else if attempt == 3 {
|
||||
w.logger.Error("VictoriaLogs failed to send logs after 4 attempts",
|
||||
"error", err,
|
||||
"entries_count", len(entries))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) sendHTTP(entries []logEntry) error {
|
||||
// Build JSON Lines payload
|
||||
var buf bytes.Buffer
|
||||
encoder := json.NewEncoder(&buf)
|
||||
|
||||
for _, entry := range entries {
|
||||
if err := encoder.Encode(entry); err != nil {
|
||||
return fmt.Errorf("failed to encode log entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build request
|
||||
url := fmt.Sprintf("%s/insert/jsonline?_stream_fields=level&_msg_field=_msg", w.url)
|
||||
req, err := http.NewRequestWithContext(w.ctx, "POST", url, &buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/x-ndjson")
|
||||
|
||||
// Set Basic Auth (password as username, empty password)
|
||||
if w.password != "" {
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(w.password + ":"))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
}
|
||||
|
||||
// Send request
|
||||
resp, err := w.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// Check response
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("VictoriaLogs returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) flushBatch(batch []logEntry) {
|
||||
if len(batch) > 0 {
|
||||
w.sendBatch(batch)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) Shutdown(timeout time.Duration) {
|
||||
w.once.Do(func() {
|
||||
// Stop accepting new logs
|
||||
w.cancel()
|
||||
|
||||
// Wait for workers to finish with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
w.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
w.logger.Info("VictoriaLogs writer shutdown gracefully")
|
||||
case <-time.After(timeout):
|
||||
w.logger.Warn("VictoriaLogs writer shutdown timeout, some logs may be lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE backups
|
||||
DROP CONSTRAINT fk_backups_storage_id;
|
||||
|
||||
ALTER TABLE backups
|
||||
ADD CONSTRAINT fk_backups_storage_id
|
||||
FOREIGN KEY (storage_id)
|
||||
REFERENCES storages (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE databases
|
||||
DROP CONSTRAINT fk_databases_workspace_id;
|
||||
|
||||
ALTER TABLE databases
|
||||
ADD CONSTRAINT fk_databases_workspace_id
|
||||
FOREIGN KEY (workspace_id)
|
||||
REFERENCES workspaces (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE backups
|
||||
DROP CONSTRAINT fk_backups_storage_id;
|
||||
|
||||
ALTER TABLE backups
|
||||
ADD CONSTRAINT fk_backups_storage_id
|
||||
FOREIGN KEY (storage_id)
|
||||
REFERENCES storages (id)
|
||||
ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE databases
|
||||
DROP CONSTRAINT fk_databases_workspace_id;
|
||||
|
||||
ALTER TABLE databases
|
||||
ADD CONSTRAINT fk_databases_workspace_id
|
||||
FOREIGN KEY (workspace_id)
|
||||
REFERENCES workspaces (id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,26 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE backup_configs
|
||||
DROP CONSTRAINT fk_backup_config_storage_id;
|
||||
|
||||
ALTER TABLE backup_configs
|
||||
ADD CONSTRAINT fk_backup_config_storage_id
|
||||
FOREIGN KEY (storage_id)
|
||||
REFERENCES storages (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE backup_configs
|
||||
DROP CONSTRAINT fk_backup_config_storage_id;
|
||||
|
||||
ALTER TABLE backup_configs
|
||||
ADD CONSTRAINT fk_backup_config_storage_id
|
||||
FOREIGN KEY (storage_id)
|
||||
REFERENCES storages (id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,31 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE password_reset_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
hashed_code TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE password_reset_codes
|
||||
ADD CONSTRAINT fk_password_reset_codes_user_id
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES users (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_password_reset_codes_user_id ON password_reset_codes (user_id);
|
||||
CREATE INDEX idx_password_reset_codes_expires_at ON password_reset_codes (expires_at);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP INDEX IF EXISTS idx_password_reset_codes_expires_at;
|
||||
DROP INDEX IF EXISTS idx_password_reset_codes_user_id;
|
||||
DROP TABLE IF EXISTS password_reset_codes;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -1 +1,5 @@
|
||||
MODE=development
|
||||
MODE=development
|
||||
VITE_GITHUB_CLIENT_ID=
|
||||
VITE_GOOGLE_CLIENT_ID=
|
||||
VITE_IS_EMAIL_CONFIGURED=false
|
||||
VITE_IS_CLOUD=false
|
||||
@@ -2,6 +2,7 @@ interface RuntimeConfig {
|
||||
IS_CLOUD?: string;
|
||||
GITHUB_CLIENT_ID?: string;
|
||||
GOOGLE_CLIENT_ID?: string;
|
||||
IS_EMAIL_CONFIGURED?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -27,7 +28,6 @@ export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://databasus.com/storages/g
|
||||
|
||||
export const APP_VERSION = (import.meta.env.VITE_APP_VERSION as string) || 'dev';
|
||||
|
||||
// First try runtime config, then build-time env var, then default to false
|
||||
export const IS_CLOUD =
|
||||
window.__RUNTIME_CONFIG__?.IS_CLOUD === 'true' || import.meta.env.VITE_IS_CLOUD === 'true';
|
||||
|
||||
@@ -37,6 +37,10 @@ export const GITHUB_CLIENT_ID =
|
||||
export const GOOGLE_CLIENT_ID =
|
||||
window.__RUNTIME_CONFIG__?.GOOGLE_CLIENT_ID || import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
|
||||
|
||||
export const IS_EMAIL_CONFIGURED =
|
||||
window.__RUNTIME_CONFIG__?.IS_EMAIL_CONFIGURED === 'true' ||
|
||||
import.meta.env.VITE_IS_EMAIL_CONFIGURED === 'true';
|
||||
|
||||
export function getOAuthRedirectUri(): string {
|
||||
return `${window.location.origin}/auth/callback`;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { InviteUserResponse } from '../model/InviteUserResponse';
|
||||
import type { IsAdminHasPasswordResponse } from '../model/IsAdminHasPasswordResponse';
|
||||
import type { OAuthCallbackRequest } from '../model/OAuthCallbackRequest';
|
||||
import type { OAuthCallbackResponse } from '../model/OAuthCallbackResponse';
|
||||
import type { ResetPasswordRequest } from '../model/ResetPasswordRequest';
|
||||
import type { SendResetPasswordCodeRequest } from '../model/SendResetPasswordCodeRequest';
|
||||
import type { SetAdminPasswordRequest } from '../model/SetAdminPasswordRequest';
|
||||
import type { SignInRequest } from '../model/SignInRequest';
|
||||
import type { SignInResponse } from '../model/SignInResponse';
|
||||
@@ -134,6 +136,24 @@ export const userApi = {
|
||||
});
|
||||
},
|
||||
|
||||
async sendResetPasswordCode(request: SendResetPasswordCodeRequest): Promise<{ message: string }> {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
requestOptions.setBody(JSON.stringify(request));
|
||||
return apiHelper.fetchPostJson(
|
||||
`${getApplicationServer()}/api/v1/users/send-reset-password-code`,
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
async resetPassword(request: ResetPasswordRequest): Promise<{ message: string }> {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
requestOptions.setBody(JSON.stringify(request));
|
||||
return apiHelper.fetchPostJson(
|
||||
`${getApplicationServer()}/api/v1/users/reset-password`,
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
isAuthorized: (): boolean => !!accessTokenHelper.getAccessToken(),
|
||||
|
||||
logout: () => {
|
||||
|
||||
@@ -18,5 +18,7 @@ export type { ListUsersRequest } from './model/ListUsersRequest';
|
||||
export type { ListUsersResponse } from './model/ListUsersResponse';
|
||||
export type { ChangeUserRoleRequest } from './model/ChangeUserRoleRequest';
|
||||
export type { UsersSettings } from './model/UsersSettings';
|
||||
export type { SendResetPasswordCodeRequest } from './model/SendResetPasswordCodeRequest';
|
||||
export type { ResetPasswordRequest } from './model/ResetPasswordRequest';
|
||||
export { UserRole } from './model/UserRole';
|
||||
export { WorkspaceRole } from './model/WorkspaceRole';
|
||||
|
||||
5
frontend/src/entity/users/model/ResetPasswordRequest.ts
Normal file
5
frontend/src/entity/users/model/ResetPasswordRequest.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ResetPasswordRequest {
|
||||
email: string;
|
||||
code: string;
|
||||
newPassword: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface SendResetPasswordCodeRequest {
|
||||
email: string;
|
||||
}
|
||||
@@ -204,6 +204,13 @@ export const EditBackupConfigComponent = ({
|
||||
try {
|
||||
const storages = await storageApi.getStorages(database.workspaceId);
|
||||
setStorages(storages);
|
||||
|
||||
if (IS_CLOUD) {
|
||||
const systemStorages = storages.filter((s) => s.isSystem);
|
||||
if (systemStorages.length > 0) {
|
||||
updateBackupConfig({ storage: systemStorages[0] });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
@@ -402,7 +402,7 @@ export const EditMariaDbSpecificDataComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnectionFailed && (
|
||||
{isConnectionFailed && !IS_CLOUD && (
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
|
||||
list.
|
||||
|
||||
@@ -425,7 +425,7 @@ export const EditMongoDbSpecificDataComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnectionFailed && (
|
||||
{isConnectionFailed && !IS_CLOUD && (
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
|
||||
list.
|
||||
|
||||
@@ -353,7 +353,7 @@ export const EditMySqlSpecificDataComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnectionFailed && (
|
||||
{isConnectionFailed && !IS_CLOUD && (
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
|
||||
list.
|
||||
|
||||
@@ -514,7 +514,7 @@ export const EditPostgreSqlSpecificDataComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnectionFailed && (
|
||||
{isConnectionFailed && !IS_CLOUD && (
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
|
||||
list.
|
||||
|
||||
@@ -59,7 +59,7 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Welcome to Databasus Playground"
|
||||
title="Welcome to Databasus playground"
|
||||
open={isVisible}
|
||||
onOk={handleClose}
|
||||
okText={
|
||||
@@ -78,9 +78,10 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold">What is Playground?</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Playground is a dev environment where you can test small databases backup and see
|
||||
Databasus in action. Databasus dev team can test new features and see issues which hard
|
||||
to detect when using self hosted (without logs or reports)
|
||||
Playground is a dev environment of Databasus development team. It is used by Databasus
|
||||
dev team to test new features and see issues which hard to detect when using self hosted
|
||||
(without logs or reports).{' '}
|
||||
<b>Here you can make backups for small and not critical databases for free</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +114,9 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
No, because playground use only read-only users and cannot affect your DB. Only issue
|
||||
you can face is instability: playground background workers frequently reloaded so backup
|
||||
can be slower or be restarted due to app restart
|
||||
can be slower or be restarted due to app restart. Do not rely production DBs on
|
||||
playground, please. At once we may clean backups or something like this. At least, check
|
||||
your backups here once a week
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Read more about settings you can{' '}
|
||||
<a
|
||||
href="https://databasus.com/access-management/#global-settings"
|
||||
href="https://databasus.com/access-management#global-settings"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="!text-blue-600"
|
||||
|
||||
@@ -42,7 +42,7 @@ export const StorageCardComponent = ({
|
||||
)}
|
||||
|
||||
{storage.isSystem && (
|
||||
<div className="mt-2 inline-block rounded-lg bg-[#ffffff10] px-2 py-1 text-xs text-gray-700 dark:text-gray-300">
|
||||
<div className="mt-2 inline-block rounded-xl bg-[#00000010] px-2 py-1 text-xs text-gray-700 dark:bg-[#ffffff10] dark:text-gray-300">
|
||||
System storage
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -143,15 +143,22 @@ export const StorageComponent = ({
|
||||
) : (
|
||||
<div>
|
||||
{!isEditName ? (
|
||||
<div className="mb-5 flex items-center text-2xl font-bold">
|
||||
{storage.name}
|
||||
{(storage.isSystem && user.role === UserRole.ADMIN) ||
|
||||
(isCanManageStorages && (
|
||||
<>
|
||||
<div className="mb-5 flex items-center text-2xl font-bold">
|
||||
{storage.name}
|
||||
{(!storage.isSystem || user.role === UserRole.ADMIN) && isCanManageStorages && (
|
||||
<div className="ml-2 cursor-pointer" onClick={() => startEdit('name')}>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{storage.isSystem && (
|
||||
<span className="mt-2 inline-block rounded-xl bg-[#00000010] px-2 py-1 text-xs text-gray-700 dark:bg-[#ffffff10] dark:text-gray-300">
|
||||
System storage
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
@@ -220,19 +227,23 @@ export const StorageComponent = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Storage settings</div>
|
||||
{!storage.isSystem ||
|
||||
(user.role === UserRole.ADMIN && (
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Storage settings</div>
|
||||
|
||||
{!isEditSettings &&
|
||||
isCanManageStorages &&
|
||||
!(storage.isSystem && user.role !== UserRole.ADMIN) ? (
|
||||
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('settings')}>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
{!isEditSettings && isCanManageStorages ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('settings')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditSettings && isCanManageStorages ? (
|
||||
@@ -254,7 +265,7 @@ export const StorageComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditSettings && (
|
||||
{!isEditSettings && (!storage.isSystem || user.role === UserRole.ADMIN) && (
|
||||
<div className="mt-5">
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
@@ -193,9 +193,18 @@ export function EditStorageComponent({
|
||||
id: undefined as unknown as string,
|
||||
workspaceId,
|
||||
name: '',
|
||||
type: StorageType.LOCAL,
|
||||
type: IS_CLOUD ? StorageType.S3 : StorageType.LOCAL,
|
||||
isSystem: false,
|
||||
localStorage: {},
|
||||
localStorage: IS_CLOUD ? undefined : {},
|
||||
s3Storage: IS_CLOUD
|
||||
? {
|
||||
s3Bucket: '',
|
||||
s3Region: '',
|
||||
s3AccessKey: '',
|
||||
s3SecretKey: '',
|
||||
s3Endpoint: '',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}, [editingStorage]);
|
||||
@@ -317,6 +326,22 @@ export function EditStorageComponent({
|
||||
|
||||
if (!storage) return <div />;
|
||||
|
||||
const storageTypeOptions = [
|
||||
{ label: 'Local storage', value: StorageType.LOCAL },
|
||||
{ label: 'S3', value: StorageType.S3 },
|
||||
{ label: 'Google Drive', value: StorageType.GOOGLE_DRIVE },
|
||||
{ label: 'NAS', value: StorageType.NAS },
|
||||
{ label: 'Azure Blob Storage', value: StorageType.AZURE_BLOB },
|
||||
{ label: 'FTP', value: StorageType.FTP },
|
||||
{ label: 'SFTP', value: StorageType.SFTP },
|
||||
{ label: 'Rclone', value: StorageType.RCLONE },
|
||||
].filter((option) => {
|
||||
if (IS_CLOUD && option.value === StorageType.LOCAL && user.role !== UserRole.ADMIN) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isShowName && (
|
||||
@@ -342,16 +367,7 @@ export function EditStorageComponent({
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={storage?.type}
|
||||
options={[
|
||||
{ label: 'Local storage', value: StorageType.LOCAL },
|
||||
{ label: 'S3', value: StorageType.S3 },
|
||||
{ label: 'Google Drive', value: StorageType.GOOGLE_DRIVE },
|
||||
{ label: 'NAS', value: StorageType.NAS },
|
||||
{ label: 'Azure Blob Storage', value: StorageType.AZURE_BLOB },
|
||||
{ label: 'FTP', value: StorageType.FTP },
|
||||
{ label: 'SFTP', value: StorageType.SFTP },
|
||||
{ label: 'Rclone', value: StorageType.RCLONE },
|
||||
]}
|
||||
options={storageTypeOptions}
|
||||
onChange={(value) => {
|
||||
setStorageType(value);
|
||||
setIsUnsaved(true);
|
||||
|
||||
@@ -18,6 +18,8 @@ interface Props {
|
||||
export function ShowStorageComponent({ storage, user }: Props) {
|
||||
if (!storage) return null;
|
||||
|
||||
if (storage?.isSystem && user.role !== UserRole.ADMIN) return <div />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center">
|
||||
@@ -39,33 +41,23 @@ export function ShowStorageComponent({ storage, user }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>{storage?.type === StorageType.S3 && <ShowS3StorageComponent storage={storage} />}</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.S3 && <ShowS3StorageComponent storage={storage} />}
|
||||
|
||||
{storage?.type === StorageType.GOOGLE_DRIVE && (
|
||||
<ShowGoogleDriveStorageComponent storage={storage} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.NAS && <ShowNASStorageComponent storage={storage} />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.AZURE_BLOB && (
|
||||
<ShowAzureBlobStorageComponent storage={storage} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.FTP && <ShowFTPStorageComponent storage={storage} />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.SFTP && <ShowSFTPStorageComponent storage={storage} />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.RCLONE && <ShowRcloneStorageComponent storage={storage} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { AdminPasswordComponent } from './ui/AdminPasswordComponent';
|
||||
export { AuthNavbarComponent } from './ui/AuthNavbarComponent';
|
||||
export { ProfileComponent } from './ui/ProfileComponent';
|
||||
export { RequestResetPasswordComponent } from './ui/RequestResetPasswordComponent';
|
||||
export { ResetPasswordComponent } from './ui/ResetPasswordComponent';
|
||||
export { SignInComponent } from './ui/SignInComponent';
|
||||
export { SignUpComponent } from './ui/SignUpComponent';
|
||||
|
||||
123
frontend/src/features/users/ui/RequestResetPasswordComponent.tsx
Normal file
123
frontend/src/features/users/ui/RequestResetPasswordComponent.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Button, Input } from 'antd';
|
||||
import { type JSX, useState } from 'react';
|
||||
|
||||
import { userApi } from '../../../entity/users';
|
||||
import { StringUtils } from '../../../shared/lib';
|
||||
import { FormValidator } from '../../../shared/lib/FormValidator';
|
||||
|
||||
interface RequestResetPasswordComponentProps {
|
||||
onSwitchToSignIn?: () => void;
|
||||
onSwitchToResetPassword?: (email: string) => void;
|
||||
}
|
||||
|
||||
export function RequestResetPasswordComponent({
|
||||
onSwitchToSignIn,
|
||||
onSwitchToResetPassword,
|
||||
}: RequestResetPasswordComponentProps): JSX.Element {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [isEmailError, setEmailError] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
const validateEmail = (): boolean => {
|
||||
if (!email) {
|
||||
setEmailError(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!FormValidator.isValidEmail(email)) {
|
||||
setEmailError(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const onSendCode = async () => {
|
||||
setError('');
|
||||
setSuccessMessage('');
|
||||
|
||||
if (validateEmail()) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await userApi.sendResetPasswordCode({ email });
|
||||
setSuccessMessage(response.message);
|
||||
|
||||
// After successful code send, switch to reset password form
|
||||
setTimeout(() => {
|
||||
if (onSwitchToResetPassword) {
|
||||
onSwitchToResetPassword(email);
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setError(StringUtils.capitalizeFirstLetter((e as Error).message));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[300px]">
|
||||
<div className="mb-5 text-center text-2xl font-bold">Reset password</div>
|
||||
|
||||
<div className="mb-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your email address and we'll send you a reset code.
|
||||
</div>
|
||||
|
||||
<div className="my-1 text-xs font-semibold">Your email</div>
|
||||
<Input
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmailError(false);
|
||||
setEmail(e.currentTarget.value.trim().toLowerCase());
|
||||
}}
|
||||
status={isEmailError ? 'error' : undefined}
|
||||
type="email"
|
||||
onPressEnter={() => {
|
||||
onSendCode();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-3" />
|
||||
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onSendCode();
|
||||
}}
|
||||
type="primary"
|
||||
>
|
||||
Send reset code
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 flex justify-center text-center text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="mt-3 flex justify-center text-center text-sm text-green-600">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSwitchToSignIn && (
|
||||
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Remember your password?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
frontend/src/features/users/ui/ResetPasswordComponent.tsx
Normal file
221
frontend/src/features/users/ui/ResetPasswordComponent.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
|
||||
import { App, Button, Input } from 'antd';
|
||||
import { type JSX, useState } from 'react';
|
||||
|
||||
import { userApi } from '../../../entity/users';
|
||||
import { StringUtils } from '../../../shared/lib';
|
||||
import { FormValidator } from '../../../shared/lib/FormValidator';
|
||||
|
||||
interface ResetPasswordComponentProps {
|
||||
onSwitchToSignIn?: () => void;
|
||||
onSwitchToRequestCode?: () => void;
|
||||
initialEmail?: string;
|
||||
}
|
||||
|
||||
export function ResetPasswordComponent({
|
||||
onSwitchToSignIn,
|
||||
onSwitchToRequestCode,
|
||||
initialEmail = '',
|
||||
}: ResetPasswordComponentProps): JSX.Element {
|
||||
const { message } = App.useApp();
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [code, setCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false);
|
||||
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
const [isEmailError, setEmailError] = useState(false);
|
||||
const [isCodeError, setCodeError] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState(false);
|
||||
const [confirmPasswordError, setConfirmPasswordError] = useState(false);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const validateFields = (): boolean => {
|
||||
let isValid = true;
|
||||
|
||||
if (!email) {
|
||||
setEmailError(true);
|
||||
isValid = false;
|
||||
} else if (!FormValidator.isValidEmail(email)) {
|
||||
setEmailError(true);
|
||||
isValid = false;
|
||||
} else {
|
||||
setEmailError(false);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setCodeError(true);
|
||||
isValid = false;
|
||||
} else if (!/^\d{6}$/.test(code)) {
|
||||
setCodeError(true);
|
||||
message.error('Code must be 6 digits');
|
||||
isValid = false;
|
||||
} else {
|
||||
setCodeError(false);
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
setPasswordError(true);
|
||||
isValid = false;
|
||||
} else if (newPassword.length < 8) {
|
||||
setPasswordError(true);
|
||||
message.error('Password must be at least 8 characters long');
|
||||
isValid = false;
|
||||
} else {
|
||||
setPasswordError(false);
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
setConfirmPasswordError(true);
|
||||
isValid = false;
|
||||
} else if (newPassword !== confirmPassword) {
|
||||
setConfirmPasswordError(true);
|
||||
message.error('Passwords do not match');
|
||||
isValid = false;
|
||||
} else {
|
||||
setConfirmPasswordError(false);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const onResetPassword = async () => {
|
||||
setError('');
|
||||
|
||||
if (validateFields()) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await userApi.resetPassword({
|
||||
email,
|
||||
code,
|
||||
newPassword,
|
||||
});
|
||||
|
||||
message.success('Password reset successfully! Redirecting to sign in...');
|
||||
|
||||
// Redirect to sign in after successful reset
|
||||
setTimeout(() => {
|
||||
if (onSwitchToSignIn) {
|
||||
onSwitchToSignIn();
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setError(StringUtils.capitalizeFirstLetter((e as Error).message));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[300px]">
|
||||
<div className="mb-5 text-center text-2xl font-bold">Reset Password</div>
|
||||
|
||||
<div className="mb-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter the code sent to your email and your new password.
|
||||
</div>
|
||||
|
||||
<div className="my-1 text-xs font-semibold">Your email</div>
|
||||
<Input
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmailError(false);
|
||||
setEmail(e.currentTarget.value.trim().toLowerCase());
|
||||
}}
|
||||
status={isEmailError ? 'error' : undefined}
|
||||
type="email"
|
||||
/>
|
||||
|
||||
<div className="my-1 text-xs font-semibold">Reset Code</div>
|
||||
<Input
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
setCodeError(false);
|
||||
const value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6);
|
||||
setCode(value);
|
||||
}}
|
||||
status={isCodeError ? 'error' : undefined}
|
||||
maxLength={6}
|
||||
/>
|
||||
|
||||
<div className="my-1 text-xs font-semibold">New Password</div>
|
||||
<Input.Password
|
||||
placeholder="********"
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setPasswordError(false);
|
||||
setNewPassword(e.currentTarget.value);
|
||||
}}
|
||||
status={passwordError ? 'error' : undefined}
|
||||
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
|
||||
visibilityToggle={{ visible: passwordVisible, onVisibleChange: setPasswordVisible }}
|
||||
/>
|
||||
|
||||
<div className="my-1 text-xs font-semibold">Confirm Password</div>
|
||||
<Input.Password
|
||||
placeholder="********"
|
||||
value={confirmPassword}
|
||||
status={confirmPasswordError ? 'error' : undefined}
|
||||
onChange={(e) => {
|
||||
setConfirmPasswordError(false);
|
||||
setConfirmPassword(e.currentTarget.value);
|
||||
}}
|
||||
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
|
||||
visibilityToggle={{
|
||||
visible: confirmPasswordVisible,
|
||||
onVisibleChange: setConfirmPasswordVisible,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-3" />
|
||||
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onResetPassword();
|
||||
}}
|
||||
type="primary"
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 flex justify-center text-center text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
{onSwitchToRequestCode && (
|
||||
<>
|
||||
Didn't receive a code?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToRequestCode}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Request new code
|
||||
</button>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
{onSwitchToSignIn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
|
||||
import { Button, Input } from 'antd';
|
||||
import { type JSX, useState } from 'react';
|
||||
|
||||
import { GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID } from '../../../constants';
|
||||
import { GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID, IS_EMAIL_CONFIGURED } from '../../../constants';
|
||||
import { userApi } from '../../../entity/users';
|
||||
import { StringUtils } from '../../../shared/lib';
|
||||
import { FormValidator } from '../../../shared/lib/FormValidator';
|
||||
@@ -11,9 +11,13 @@ import { GoogleOAuthComponent } from './oauth/GoogleOAuthComponent';
|
||||
|
||||
interface SignInComponentProps {
|
||||
onSwitchToSignUp?: () => void;
|
||||
onSwitchToResetPassword?: () => void;
|
||||
}
|
||||
|
||||
export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX.Element {
|
||||
export function SignInComponent({
|
||||
onSwitchToSignUp,
|
||||
onSwitchToResetPassword,
|
||||
}: SignInComponentProps): JSX.Element {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
@@ -81,7 +85,9 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-gray-500 dark:text-gray-400">or continue</span>
|
||||
<span className="bg-white px-2 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
or continue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -131,18 +137,26 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSwitchToSignUp && (
|
||||
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?{' '}
|
||||
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignUp}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
<br />
|
||||
{IS_EMAIL_CONFIGURED && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignUp}
|
||||
onClick={onSwitchToResetPassword}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Sign up
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,9 @@ export function SignUpComponent({ onSwitchToSignIn }: SignUpComponentProps): JSX
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-gray-500 dark:text-gray-400">or continue</span>
|
||||
<span className="bg-white px-2 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
or continue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -101,7 +101,9 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
width: 350,
|
||||
render: (message: string) => <span className="text-xs text-gray-900">{message}</span>,
|
||||
render: (message: string) => (
|
||||
<span className="text-xs text-gray-900 dark:text-white">{message}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
@@ -111,7 +113,9 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
|
||||
render: (workspaceId: string | undefined) => (
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${
|
||||
workspaceId ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-600'
|
||||
workspaceId
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{workspaceId || '-'}
|
||||
@@ -127,7 +131,7 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
|
||||
const date = dayjs(createdAt);
|
||||
const timeFormat = getUserTimeFormat();
|
||||
return (
|
||||
<span className="text-xs text-gray-700">
|
||||
<span className="text-xs text-gray-700 dark:text-white">
|
||||
{`${date.format(timeFormat.format)} (${date.fromNow()})`}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PlaygroundWarningComponent } from '../features/playground';
|
||||
import {
|
||||
AdminPasswordComponent,
|
||||
AuthNavbarComponent,
|
||||
RequestResetPasswordComponent,
|
||||
ResetPasswordComponent,
|
||||
SignInComponent,
|
||||
SignUpComponent,
|
||||
} from '../features/users';
|
||||
@@ -14,7 +16,10 @@ import { useScreenHeight } from '../shared/hooks';
|
||||
|
||||
export function AuthPageComponent() {
|
||||
const [isAdminHasPassword, setIsAdminHasPassword] = useState(false);
|
||||
const [authMode, setAuthMode] = useState<'signIn' | 'signUp'>('signUp');
|
||||
const [authMode, setAuthMode] = useState<'signIn' | 'signUp' | 'requestReset' | 'resetPassword'>(
|
||||
'signUp',
|
||||
);
|
||||
const [resetEmail, setResetEmail] = useState('');
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const screenHeight = useScreenHeight();
|
||||
|
||||
@@ -51,8 +56,25 @@ export function AuthPageComponent() {
|
||||
{isAdminHasPassword ? (
|
||||
authMode === 'signUp' ? (
|
||||
<SignUpComponent onSwitchToSignIn={() => setAuthMode('signIn')} />
|
||||
) : authMode === 'signIn' ? (
|
||||
<SignInComponent
|
||||
onSwitchToSignUp={() => setAuthMode('signUp')}
|
||||
onSwitchToResetPassword={() => setAuthMode('requestReset')}
|
||||
/>
|
||||
) : authMode === 'requestReset' ? (
|
||||
<RequestResetPasswordComponent
|
||||
onSwitchToSignIn={() => setAuthMode('signIn')}
|
||||
onSwitchToResetPassword={(email) => {
|
||||
setResetEmail(email);
|
||||
setAuthMode('resetPassword');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SignInComponent onSwitchToSignUp={() => setAuthMode('signUp')} />
|
||||
<ResetPasswordComponent
|
||||
onSwitchToSignIn={() => setAuthMode('signIn')}
|
||||
onSwitchToRequestCode={() => setAuthMode('requestReset')}
|
||||
initialEmail={resetEmail}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<AdminPasswordComponent onPasswordSet={checkAdminPasswordStatus} />
|
||||
|
||||
Reference in New Issue
Block a user