mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77c2712ebb | ||
|
|
a9dc29f82c | ||
|
|
c934a45dca | ||
|
|
d4acdf2826 | ||
|
|
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 | ||
|
|
ae27f74c2e | ||
|
|
9457516bb9 | ||
|
|
a36fc5bf8c | ||
|
|
03ada5806d | ||
|
|
a6675390e5 | ||
|
|
af2f978876 |
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
|
||||
|
||||
@@ -18,6 +18,13 @@ repos:
|
||||
files: ^frontend/.*\.(ts|tsx|js|jsx)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: frontend-build
|
||||
name: Frontend Build
|
||||
entry: bash -c "cd frontend && npm run build"
|
||||
language: system
|
||||
files: ^frontend/.*\.(ts|tsx|js|jsx|json|css)$
|
||||
pass_filenames: false
|
||||
|
||||
# Backend checks
|
||||
- repo: local
|
||||
hooks:
|
||||
|
||||
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:**
|
||||
|
||||
53
Dockerfile
53
Dockerfile
@@ -251,6 +251,33 @@ fi
|
||||
# PostgreSQL 17 binary paths
|
||||
PG_BIN="/usr/lib/postgresql/17/bin"
|
||||
|
||||
# Generate runtime configuration for frontend
|
||||
echo "Generating runtime configuration..."
|
||||
|
||||
# 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:-}',
|
||||
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
|
||||
@@ -372,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,22 @@ 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"`
|
||||
|
||||
ShowDbInstallationVerificationLogs bool `env:"SHOW_DB_INSTALLATION_VERIFICATION_LOGS"`
|
||||
@@ -89,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"`
|
||||
@@ -112,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 (
|
||||
@@ -182,6 +193,11 @@ func loadEnvVariables() {
|
||||
env.IsSkipExternalResourcesTests = false
|
||||
}
|
||||
|
||||
// Set default value for IsCloud if not defined
|
||||
if os.Getenv("IS_CLOUD") == "" {
|
||||
env.IsCloud = false
|
||||
}
|
||||
|
||||
for _, arg := range os.Args {
|
||||
if strings.Contains(arg, "test") {
|
||||
env.IsTesting = true
|
||||
@@ -189,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)
|
||||
@@ -259,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)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type BackupConfigController struct {
|
||||
|
||||
func (c *BackupConfigController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.POST("/backup-configs/save", c.SaveBackupConfig)
|
||||
router.GET("/backup-configs/database/:id/plan", c.GetDatabasePlan)
|
||||
router.GET("/backup-configs/database/:id", c.GetBackupConfigByDbID)
|
||||
router.GET("/backup-configs/storage/:id/is-using", c.IsStorageUsing)
|
||||
router.GET("/backup-configs/storage/:id/databases-count", c.CountDatabasesForStorage)
|
||||
@@ -92,6 +93,39 @@ func (c *BackupConfigController) GetBackupConfigByDbID(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, backupConfig)
|
||||
}
|
||||
|
||||
// GetDatabasePlan
|
||||
// @Summary Get database plan by database ID
|
||||
// @Description Get the plan limits for a specific database (max backup size, max total size, max storage period)
|
||||
// @Tags backup-configs
|
||||
// @Produce json
|
||||
// @Param id path string true "Database ID"
|
||||
// @Success 200 {object} plans.DatabasePlan
|
||||
// @Failure 400 {object} map[string]string "Invalid database ID"
|
||||
// @Failure 401 {object} map[string]string "User not authenticated"
|
||||
// @Failure 404 {object} map[string]string "Database not found or access denied"
|
||||
// @Router /backup-configs/database/{id}/plan [get]
|
||||
func (c *BackupConfigController) GetDatabasePlan(ctx *gin.Context) {
|
||||
user, ok := users_middleware.GetUserFromContext(ctx)
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(ctx.Param("id"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := c.backupConfigService.GetDatabasePlan(user, id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "database plan not found"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, plan)
|
||||
}
|
||||
|
||||
// IsStorageUsing
|
||||
// @Summary Check if storage is being used
|
||||
// @Description Check if a storage is currently being used by any backup configuration
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -16,11 +17,14 @@ import (
|
||||
"databasus-backend/internal/features/databases/databases/postgresql"
|
||||
"databasus-backend/internal/features/intervals"
|
||||
"databasus-backend/internal/features/notifiers"
|
||||
plans "databasus-backend/internal/features/plan"
|
||||
"databasus-backend/internal/features/storages"
|
||||
local_storage "databasus-backend/internal/features/storages/models/local"
|
||||
users_enums "databasus-backend/internal/features/users/enums"
|
||||
users_testing "databasus-backend/internal/features/users/testing"
|
||||
workspaces_controllers "databasus-backend/internal/features/workspaces/controllers"
|
||||
workspaces_testing "databasus-backend/internal/features/workspaces/testing"
|
||||
"databasus-backend/internal/storage"
|
||||
"databasus-backend/internal/util/period"
|
||||
test_utils "databasus-backend/internal/util/testing"
|
||||
"databasus-backend/internal/util/tools"
|
||||
@@ -89,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)
|
||||
@@ -152,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"
|
||||
@@ -242,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)
|
||||
@@ -290,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,
|
||||
@@ -300,14 +324,214 @@ func Test_GetBackupConfigByDbID_ReturnsDefaultConfigForNewDatabase(t *testing.T)
|
||||
&response,
|
||||
)
|
||||
|
||||
var plan plans.DatabasePlan
|
||||
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/database/"+database.ID.String()+"/plan",
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&plan,
|
||||
)
|
||||
|
||||
assert.Equal(t, database.ID, response.DatabaseID)
|
||||
assert.False(t, response.IsBackupsEnabled)
|
||||
assert.Equal(t, period.PeriodWeek, response.StorePeriod)
|
||||
assert.Equal(t, plan.MaxStoragePeriod, response.StorePeriod)
|
||||
assert.Equal(t, plan.MaxBackupSizeMB, response.MaxBackupSizeMB)
|
||||
assert.Equal(t, plan.MaxBackupsTotalSizeMB, response.MaxBackupsTotalSizeMB)
|
||||
assert.True(t, response.IsRetryIfFailed)
|
||||
assert.Equal(t, 3, response.MaxFailedTriesCount)
|
||||
assert.NotNil(t, response.BackupInterval)
|
||||
}
|
||||
|
||||
func Test_GetDatabasePlan_ForNewDatabase_PlanAlwaysReturned(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)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var response plans.DatabasePlan
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/database/"+database.ID.String()+"/plan",
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&response,
|
||||
)
|
||||
|
||||
assert.Equal(t, database.ID, response.DatabaseID)
|
||||
assert.NotNil(t, response.MaxBackupSizeMB)
|
||||
assert.NotNil(t, response.MaxBackupsTotalSizeMB)
|
||||
assert.NotEmpty(t, response.MaxStoragePeriod)
|
||||
}
|
||||
|
||||
func Test_SaveBackupConfig_WhenPlanLimitsAreAdjusted_ValidationEnforced(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)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Get plan via API (triggers auto-creation)
|
||||
var plan plans.DatabasePlan
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/database/"+database.ID.String()+"/plan",
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&plan,
|
||||
)
|
||||
|
||||
assert.Equal(t, database.ID, plan.DatabaseID)
|
||||
|
||||
// Adjust plan limits directly in database to fixed restrictive values
|
||||
err := storage.GetDb().Model(&plans.DatabasePlan{}).
|
||||
Where("database_id = ?", database.ID).
|
||||
Updates(map[string]any{
|
||||
"max_backup_size_mb": 100,
|
||||
"max_backups_total_size_mb": 1000,
|
||||
"max_storage_period": period.PeriodMonth,
|
||||
}).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test 1: Try to save backup config with exceeded backup size limit
|
||||
timeOfDay := "04:00"
|
||||
backupConfigExceededSize := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
MaxBackupSizeMB: 200, // Exceeds limit of 100
|
||||
MaxBackupsTotalSizeMB: 800,
|
||||
}
|
||||
|
||||
respExceededSize := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner.Token,
|
||||
backupConfigExceededSize,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
assert.Contains(t, string(respExceededSize.Body), "max backup size exceeds plan limit")
|
||||
|
||||
// Test 2: Try to save backup config with exceeded total size limit
|
||||
backupConfigExceededTotal := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
MaxBackupSizeMB: 50,
|
||||
MaxBackupsTotalSizeMB: 2000, // Exceeds limit of 1000
|
||||
}
|
||||
|
||||
respExceededTotal := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner.Token,
|
||||
backupConfigExceededTotal,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
assert.Contains(t, string(respExceededTotal.Body), "max total backups size exceeds plan limit")
|
||||
|
||||
// Test 3: Try to save backup config with exceeded storage period limit
|
||||
backupConfigExceededPeriod := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodYear, // Exceeds limit of Month
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
MaxBackupSizeMB: 80,
|
||||
MaxBackupsTotalSizeMB: 800,
|
||||
}
|
||||
|
||||
respExceededPeriod := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner.Token,
|
||||
backupConfigExceededPeriod,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
assert.Contains(t, string(respExceededPeriod.Body), "storage period exceeds plan limit")
|
||||
|
||||
// Test 4: Save backup config within all limits - should succeed
|
||||
backupConfigValid := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodWeek, // Within Month limit
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
MaxBackupSizeMB: 80, // Within 100 limit
|
||||
MaxBackupsTotalSizeMB: 800, // Within 1000 limit
|
||||
}
|
||||
|
||||
var responseValid BackupConfig
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner.Token,
|
||||
backupConfigValid,
|
||||
http.StatusOK,
|
||||
&responseValid,
|
||||
)
|
||||
|
||||
assert.Equal(t, database.ID, responseValid.DatabaseID)
|
||||
assert.Equal(t, int64(80), responseValid.MaxBackupSizeMB)
|
||||
assert.Equal(t, int64(800), responseValid.MaxBackupsTotalSizeMB)
|
||||
assert.Equal(t, period.PeriodWeek, responseValid.StorePeriod)
|
||||
}
|
||||
|
||||
func Test_IsStorageUsing_PermissionsEnforced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -340,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
|
||||
@@ -372,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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -387,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,
|
||||
@@ -426,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,
|
||||
@@ -536,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)
|
||||
@@ -628,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,
|
||||
}
|
||||
@@ -668,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,
|
||||
}
|
||||
@@ -695,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,
|
||||
@@ -774,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,
|
||||
@@ -863,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,
|
||||
@@ -945,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(
|
||||
@@ -1048,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,
|
||||
@@ -1160,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,
|
||||
@@ -1271,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,
|
||||
@@ -1342,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,
|
||||
@@ -1399,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,
|
||||
@@ -1443,6 +1772,115 @@ func Test_TransferDatabase_TargetStorageFromDifferentWorkspace_ReturnsBadRequest
|
||||
assert.Contains(t, string(testResp.Body), "target storage does not belong to target workspace")
|
||||
}
|
||||
|
||||
func Test_SaveBackupConfig_WithSystemStorage_CanBeUsedByAnyDatabase(t *testing.T) {
|
||||
router := createTestRouterWithStorageForTransfer()
|
||||
|
||||
owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
|
||||
workspaceA := workspaces_testing.CreateTestWorkspace("Workspace A", owner1, router)
|
||||
workspaceB := workspaces_testing.CreateTestWorkspace("Workspace B", owner2, router)
|
||||
|
||||
databaseA := createTestDatabaseViaAPI("Database A", workspaceA.ID, owner1.Token, router)
|
||||
|
||||
// Test 1: Regular storage from workspace B cannot be used by database in workspace A
|
||||
regularStorageB := createTestStorage(workspaceB.ID)
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigWithRegularStorage := BackupConfig{
|
||||
DatabaseID: databaseA.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
StorageID: ®ularStorageB.ID,
|
||||
Storage: regularStorageB,
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
}
|
||||
|
||||
respRegular := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner1.Token,
|
||||
backupConfigWithRegularStorage,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(respRegular.Body), "storage does not belong to the same workspace")
|
||||
|
||||
// Test 2: System storage from workspace B CAN be used by database in workspace A
|
||||
systemStorageB := &storages.Storage{
|
||||
WorkspaceID: workspaceB.ID,
|
||||
Type: storages.StorageTypeLocal,
|
||||
Name: "Test System Storage " + uuid.New().String(),
|
||||
IsSystem: true,
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
|
||||
var savedSystemStorage storages.Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*systemStorageB,
|
||||
http.StatusOK,
|
||||
&savedSystemStorage,
|
||||
)
|
||||
|
||||
assert.True(t, savedSystemStorage.IsSystem)
|
||||
|
||||
backupConfigWithSystemStorage := BackupConfig{
|
||||
DatabaseID: databaseA.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
StorageID: &savedSystemStorage.ID,
|
||||
Storage: &savedSystemStorage,
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
}
|
||||
|
||||
var savedConfig BackupConfig
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner1.Token,
|
||||
backupConfigWithSystemStorage,
|
||||
http.StatusOK,
|
||||
&savedConfig,
|
||||
)
|
||||
|
||||
assert.Equal(t, databaseA.ID, savedConfig.DatabaseID)
|
||||
assert.NotNil(t, savedConfig.StorageID)
|
||||
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(
|
||||
name string,
|
||||
workspaceID uuid.UUID,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"databasus-backend/internal/features/databases"
|
||||
"databasus-backend/internal/features/notifiers"
|
||||
plans "databasus-backend/internal/features/plan"
|
||||
"databasus-backend/internal/features/storages"
|
||||
workspaces_services "databasus-backend/internal/features/workspaces/services"
|
||||
"databasus-backend/internal/util/logger"
|
||||
@@ -18,6 +19,7 @@ var backupConfigService = &BackupConfigService{
|
||||
storages.GetStorageService(),
|
||||
notifiers.GetNotifierService(),
|
||||
workspaces_services.GetWorkspaceService(),
|
||||
plans.GetDatabasePlanService(),
|
||||
nil,
|
||||
}
|
||||
var backupConfigController = &BackupConfigController{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package backups_config
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/config"
|
||||
"databasus-backend/internal/features/intervals"
|
||||
plans "databasus-backend/internal/features/plan"
|
||||
"databasus-backend/internal/features/storages"
|
||||
"databasus-backend/internal/util/period"
|
||||
"errors"
|
||||
@@ -75,7 +77,7 @@ func (b *BackupConfig) AfterFind(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BackupConfig) Validate() error {
|
||||
func (b *BackupConfig) Validate(plan *plans.DatabasePlan) error {
|
||||
// Backup interval is required either as ID or as object
|
||||
if b.BackupIntervalID == uuid.Nil && b.BackupInterval == nil {
|
||||
return errors.New("backup interval is required")
|
||||
@@ -94,6 +96,12 @@ func (b *BackupConfig) Validate() error {
|
||||
return errors.New("encryption must be NONE or ENCRYPTED")
|
||||
}
|
||||
|
||||
if config.GetEnv().IsCloud {
|
||||
if b.Encryption != BackupEncryptionEncrypted {
|
||||
return errors.New("encryption is mandatory for cloud storage")
|
||||
}
|
||||
}
|
||||
|
||||
if b.MaxBackupSizeMB < 0 {
|
||||
return errors.New("max backup size must be non-negative")
|
||||
}
|
||||
@@ -102,6 +110,29 @@ func (b *BackupConfig) Validate() error {
|
||||
return errors.New("max backups total size must be non-negative")
|
||||
}
|
||||
|
||||
// Validate against plan limits
|
||||
// Check storage period limit
|
||||
if plan.MaxStoragePeriod != period.PeriodForever {
|
||||
if b.StorePeriod.CompareTo(plan.MaxStoragePeriod) > 0 {
|
||||
return errors.New("storage period exceeds plan limit")
|
||||
}
|
||||
}
|
||||
|
||||
// Check max backup size limit (0 in plan means unlimited)
|
||||
if plan.MaxBackupSizeMB > 0 {
|
||||
if b.MaxBackupSizeMB == 0 || b.MaxBackupSizeMB > plan.MaxBackupSizeMB {
|
||||
return errors.New("max backup size exceeds plan limit")
|
||||
}
|
||||
}
|
||||
|
||||
// Check max total backups size limit (0 in plan means unlimited)
|
||||
if plan.MaxBackupsTotalSizeMB > 0 {
|
||||
if b.MaxBackupsTotalSizeMB == 0 ||
|
||||
b.MaxBackupsTotalSizeMB > plan.MaxBackupsTotalSizeMB {
|
||||
return errors.New("max total backups size exceeds plan limit")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
391
backend/internal/features/backups/config/model_test.go
Normal file
391
backend/internal/features/backups/config/model_test.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package backups_config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"databasus-backend/internal/features/intervals"
|
||||
plans "databasus-backend/internal/features/plan"
|
||||
"databasus-backend/internal/util/period"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Validate_WhenStoragePeriodIsWeekAndPlanAllowsMonth_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodWeek
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodMonth
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenStoragePeriodIsYearAndPlanAllowsMonth_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodYear
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodMonth
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "storage period exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenStoragePeriodIsForeverAndPlanAllowsForever_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodForever
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodForever
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenStoragePeriodIsForeverAndPlanAllowsYear_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodForever
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodYear
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "storage period exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenStoragePeriodEqualsExactPlanLimit_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodMonth
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodMonth
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenBackupSize100MBAndPlanAllows500MB_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = 100
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupSizeMB = 500
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenBackupSize500MBAndPlanAllows100MB_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = 500
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupSizeMB = 100
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max backup size exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenBackupSizeIsUnlimitedAndPlanAllowsUnlimited_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupSizeMB = 0
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenBackupSizeIsUnlimitedAndPlanHas500MBLimit_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupSizeMB = 500
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max backup size exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenBackupSizeEqualsExactPlanLimit_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = 500
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupSizeMB = 500
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenTotalSize1GBAndPlanAllows5GB_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = 1000
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupsTotalSizeMB = 5000
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenTotalSize5GBAndPlanAllows1GB_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = 5000
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupsTotalSizeMB = 1000
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max total backups size exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenTotalSizeIsUnlimitedAndPlanAllowsUnlimited_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupsTotalSizeMB = 0
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenTotalSizeIsUnlimitedAndPlanHas1GBLimit_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupsTotalSizeMB = 1000
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max total backups size exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenTotalSizeEqualsExactPlanLimit_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = 5000
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupsTotalSizeMB = 5000
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenAllLimitsAreUnlimitedInPlan_AnyConfigurationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodForever
|
||||
config.MaxBackupSizeMB = 0
|
||||
config.MaxBackupsTotalSizeMB = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenMultipleLimitsExceeded_ValidationFailsWithFirstError(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodYear
|
||||
config.MaxBackupSizeMB = 500
|
||||
config.MaxBackupsTotalSizeMB = 5000
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodMonth
|
||||
plan.MaxBackupSizeMB = 100
|
||||
plan.MaxBackupsTotalSizeMB = 1000
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.Error(t, err)
|
||||
assert.EqualError(t, err, "storage period exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenConfigHasInvalidIntervalButPlanIsValid_ValidationFailsOnInterval(
|
||||
t *testing.T,
|
||||
) {
|
||||
config := createValidBackupConfig()
|
||||
config.BackupIntervalID = uuid.Nil
|
||||
config.BackupInterval = nil
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "backup interval is required")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenIntervalIsMissing_ValidationFailsRegardlessOfPlan(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.BackupIntervalID = uuid.Nil
|
||||
config.BackupInterval = nil
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "backup interval is required")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenRetryEnabledButMaxTriesIsZero_ValidationFailsRegardlessOfPlan(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.IsRetryIfFailed = true
|
||||
config.MaxFailedTriesCount = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max failed tries count must be greater than 0")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenEncryptionIsInvalid_ValidationFailsRegardlessOfPlan(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.Encryption = "INVALID"
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "encryption must be NONE or ENCRYPTED")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenStoragePeriodIsEmpty_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = ""
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "store period is required")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenMaxBackupSizeIsNegative_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = -100
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max backup size must be non-negative")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenMaxTotalSizeIsNegative_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = -1000
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max backups total size must be non-negative")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenPlanLimitsAreAtBoundary_ValidationWorks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configPeriod period.Period
|
||||
planPeriod period.Period
|
||||
configSize int64
|
||||
planSize int64
|
||||
configTotal int64
|
||||
planTotal int64
|
||||
shouldSucceed bool
|
||||
}{
|
||||
{
|
||||
name: "all values just under limit",
|
||||
configPeriod: period.PeriodWeek,
|
||||
planPeriod: period.PeriodMonth,
|
||||
configSize: 99,
|
||||
planSize: 100,
|
||||
configTotal: 999,
|
||||
planTotal: 1000,
|
||||
shouldSucceed: true,
|
||||
},
|
||||
{
|
||||
name: "all values equal to limit",
|
||||
configPeriod: period.PeriodMonth,
|
||||
planPeriod: period.PeriodMonth,
|
||||
configSize: 100,
|
||||
planSize: 100,
|
||||
configTotal: 1000,
|
||||
planTotal: 1000,
|
||||
shouldSucceed: true,
|
||||
},
|
||||
{
|
||||
name: "period just over limit",
|
||||
configPeriod: period.Period3Month,
|
||||
planPeriod: period.PeriodMonth,
|
||||
configSize: 100,
|
||||
planSize: 100,
|
||||
configTotal: 1000,
|
||||
planTotal: 1000,
|
||||
shouldSucceed: false,
|
||||
},
|
||||
{
|
||||
name: "size just over limit",
|
||||
configPeriod: period.PeriodMonth,
|
||||
planPeriod: period.PeriodMonth,
|
||||
configSize: 101,
|
||||
planSize: 100,
|
||||
configTotal: 1000,
|
||||
planTotal: 1000,
|
||||
shouldSucceed: false,
|
||||
},
|
||||
{
|
||||
name: "total size just over limit",
|
||||
configPeriod: period.PeriodMonth,
|
||||
planPeriod: period.PeriodMonth,
|
||||
configSize: 100,
|
||||
planSize: 100,
|
||||
configTotal: 1001,
|
||||
planTotal: 1000,
|
||||
shouldSucceed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = tt.configPeriod
|
||||
config.MaxBackupSizeMB = tt.configSize
|
||||
config.MaxBackupsTotalSizeMB = tt.configTotal
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = tt.planPeriod
|
||||
plan.MaxBackupSizeMB = tt.planSize
|
||||
plan.MaxBackupsTotalSizeMB = tt.planTotal
|
||||
|
||||
err := config.Validate(plan)
|
||||
if tt.shouldSucceed {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createValidBackupConfig() *BackupConfig {
|
||||
intervalID := uuid.New()
|
||||
return &BackupConfig{
|
||||
DatabaseID: uuid.New(),
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodMonth,
|
||||
BackupIntervalID: intervalID,
|
||||
BackupInterval: &intervals.Interval{ID: intervalID},
|
||||
SendNotificationsOn: []BackupNotificationType{},
|
||||
IsRetryIfFailed: false,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
MaxBackupSizeMB: 100,
|
||||
MaxBackupsTotalSizeMB: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
func createUnlimitedPlan() *plans.DatabasePlan {
|
||||
return &plans.DatabasePlan{
|
||||
DatabaseID: uuid.New(),
|
||||
MaxBackupSizeMB: 0,
|
||||
MaxBackupsTotalSizeMB: 0,
|
||||
MaxStoragePeriod: period.PeriodForever,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"databasus-backend/internal/features/databases"
|
||||
"databasus-backend/internal/features/intervals"
|
||||
"databasus-backend/internal/features/notifiers"
|
||||
plans "databasus-backend/internal/features/plan"
|
||||
"databasus-backend/internal/features/storages"
|
||||
users_models "databasus-backend/internal/features/users/models"
|
||||
workspaces_services "databasus-backend/internal/features/workspaces/services"
|
||||
"databasus-backend/internal/util/period"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -20,6 +20,7 @@ type BackupConfigService struct {
|
||||
storageService *storages.StorageService
|
||||
notifierService *notifiers.NotifierService
|
||||
workspaceService *workspaces_services.WorkspaceService
|
||||
databasePlanService *plans.DatabasePlanService
|
||||
|
||||
dbStorageChangeListener BackupConfigStorageChangeListener
|
||||
}
|
||||
@@ -45,7 +46,12 @@ func (s *BackupConfigService) SaveBackupConfigWithAuth(
|
||||
user *users_models.User,
|
||||
backupConfig *BackupConfig,
|
||||
) (*BackupConfig, error) {
|
||||
if err := backupConfig.Validate(); err != nil {
|
||||
plan, err := s.databasePlanService.GetDatabasePlan(backupConfig.DatabaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := backupConfig.Validate(plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -71,7 +77,7 @@ func (s *BackupConfigService) SaveBackupConfigWithAuth(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if storage.WorkspaceID != *database.WorkspaceID {
|
||||
if storage.WorkspaceID != *database.WorkspaceID && !storage.IsSystem {
|
||||
return nil, errors.New("storage does not belong to the same workspace as the database")
|
||||
}
|
||||
}
|
||||
@@ -82,7 +88,12 @@ func (s *BackupConfigService) SaveBackupConfigWithAuth(
|
||||
func (s *BackupConfigService) SaveBackupConfig(
|
||||
backupConfig *BackupConfig,
|
||||
) (*BackupConfig, error) {
|
||||
if err := backupConfig.Validate(); err != nil {
|
||||
plan, err := s.databasePlanService.GetDatabasePlan(backupConfig.DatabaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := backupConfig.Validate(plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -120,6 +131,18 @@ func (s *BackupConfigService) GetBackupConfigByDbIdWithAuth(
|
||||
return s.GetBackupConfigByDbId(databaseID)
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) GetDatabasePlan(
|
||||
user *users_models.User,
|
||||
databaseID uuid.UUID,
|
||||
) (*plans.DatabasePlan, error) {
|
||||
_, err := s.databaseService.GetDatabase(user, databaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.databasePlanService.GetDatabasePlan(databaseID)
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) GetBackupConfigByDbId(
|
||||
databaseID uuid.UUID,
|
||||
) (*BackupConfig, error) {
|
||||
@@ -194,12 +217,19 @@ func (s *BackupConfigService) CreateDisabledBackupConfig(databaseID uuid.UUID) e
|
||||
func (s *BackupConfigService) initializeDefaultConfig(
|
||||
databaseID uuid.UUID,
|
||||
) error {
|
||||
plan, err := s.databasePlanService.GetDatabasePlan(databaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
timeOfDay := "04:00"
|
||||
|
||||
_, err := s.backupConfigRepository.Save(&BackupConfig{
|
||||
DatabaseID: databaseID,
|
||||
IsBackupsEnabled: false,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
_, err = s.backupConfigRepository.Save(&BackupConfig{
|
||||
DatabaseID: databaseID,
|
||||
IsBackupsEnabled: false,
|
||||
StorePeriod: plan.MaxStoragePeriod,
|
||||
MaxBackupSizeMB: plan.MaxBackupSizeMB,
|
||||
MaxBackupsTotalSizeMB: plan.MaxBackupsTotalSizeMB,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package databases
|
||||
|
||||
import (
|
||||
"context"
|
||||
"databasus-backend/internal/features/databases/databases/mariadb"
|
||||
"databasus-backend/internal/features/databases/databases/mongodb"
|
||||
"databasus-backend/internal/features/databases/databases/mysql"
|
||||
@@ -84,6 +85,25 @@ func (d *Database) TestConnection(
|
||||
return d.getSpecificDatabase().TestConnection(logger, encryptor, d.ID)
|
||||
}
|
||||
|
||||
func (d *Database) IsUserReadOnly(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
) (bool, []string, error) {
|
||||
switch d.Type {
|
||||
case DatabaseTypePostgres:
|
||||
return d.Postgresql.IsUserReadOnly(ctx, logger, encryptor, d.ID)
|
||||
case DatabaseTypeMysql:
|
||||
return d.Mysql.IsUserReadOnly(ctx, logger, encryptor, d.ID)
|
||||
case DatabaseTypeMariadb:
|
||||
return d.Mariadb.IsUserReadOnly(ctx, logger, encryptor, d.ID)
|
||||
case DatabaseTypeMongodb:
|
||||
return d.Mongodb.IsUserReadOnly(ctx, logger, encryptor, d.ID)
|
||||
default:
|
||||
return false, nil, errors.New("read-only check not supported for this database type")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) HideSensitiveData() {
|
||||
d.getSpecificDatabase().HideSensitiveData()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"databasus-backend/internal/config"
|
||||
audit_logs "databasus-backend/internal/features/audit_logs"
|
||||
"databasus-backend/internal/features/databases/databases/mariadb"
|
||||
"databasus-backend/internal/features/databases/databases/mongodb"
|
||||
@@ -86,6 +87,23 @@ func (s *DatabaseService) CreateDatabase(
|
||||
return nil, fmt.Errorf("failed to auto-detect database data: %w", err)
|
||||
}
|
||||
|
||||
if config.GetEnv().IsCloud {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
isReadOnly, permissions, err := database.IsUserReadOnly(ctx, s.logger, s.fieldEncryptor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify user permissions: %w", err)
|
||||
}
|
||||
|
||||
if !isReadOnly {
|
||||
return nil, fmt.Errorf(
|
||||
"in cloud mode, only read-only database users are allowed (user has permissions: %v)",
|
||||
permissions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.EncryptSensitiveFields(s.fieldEncryptor); err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt sensitive fields: %w", err)
|
||||
}
|
||||
@@ -153,6 +171,27 @@ func (s *DatabaseService) UpdateDatabase(
|
||||
return fmt.Errorf("failed to auto-detect database data: %w", err)
|
||||
}
|
||||
|
||||
if config.GetEnv().IsCloud {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
isReadOnly, permissions, err := existingDatabase.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify user permissions: %w", err)
|
||||
}
|
||||
|
||||
if !isReadOnly {
|
||||
return fmt.Errorf(
|
||||
"in cloud mode, only read-only database users are allowed (user has permissions: %v)",
|
||||
permissions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := existingDatabase.EncryptSensitiveFields(s.fieldEncryptor); err != nil {
|
||||
return fmt.Errorf("failed to encrypt sensitive fields: %w", err)
|
||||
}
|
||||
@@ -649,38 +688,7 @@ func (s *DatabaseService) IsUserReadOnly(
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
switch usingDatabase.Type {
|
||||
case DatabaseTypePostgres:
|
||||
return usingDatabase.Postgresql.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
usingDatabase.ID,
|
||||
)
|
||||
case DatabaseTypeMysql:
|
||||
return usingDatabase.Mysql.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
usingDatabase.ID,
|
||||
)
|
||||
case DatabaseTypeMariadb:
|
||||
return usingDatabase.Mariadb.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
usingDatabase.ID,
|
||||
)
|
||||
case DatabaseTypeMongodb:
|
||||
return usingDatabase.Mongodb.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
usingDatabase.ID,
|
||||
)
|
||||
default:
|
||||
return false, nil, errors.New("read-only check not supported for this database type")
|
||||
}
|
||||
return usingDatabase.IsUserReadOnly(ctx, s.logger, s.fieldEncryptor)
|
||||
}
|
||||
|
||||
func (s *DatabaseService) CreateReadOnlyUser(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,15 @@ import (
|
||||
type DiskService struct{}
|
||||
|
||||
func (s *DiskService) GetDiskUsage() (*DiskUsage, error) {
|
||||
if config.GetEnv().IsCloud {
|
||||
return &DiskUsage{
|
||||
Platform: PlatformLinux,
|
||||
TotalSpaceBytes: 100,
|
||||
UsedSpaceBytes: 0,
|
||||
FreeSpaceBytes: 100,
|
||||
}, nil
|
||||
}
|
||||
|
||||
platform := s.detectPlatform()
|
||||
|
||||
var path string
|
||||
|
||||
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(
|
||||
|
||||
20
backend/internal/features/plan/di.go
Normal file
20
backend/internal/features/plan/di.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package plans
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var databasePlanRepository = &DatabasePlanRepository{}
|
||||
|
||||
var databasePlanService = &DatabasePlanService{
|
||||
databasePlanRepository,
|
||||
logger.GetLogger(),
|
||||
}
|
||||
|
||||
func GetDatabasePlanService() *DatabasePlanService {
|
||||
return databasePlanService
|
||||
}
|
||||
|
||||
func GetDatabasePlanRepository() *DatabasePlanRepository {
|
||||
return databasePlanRepository
|
||||
}
|
||||
19
backend/internal/features/plan/model.go
Normal file
19
backend/internal/features/plan/model.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package plans
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/util/period"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DatabasePlan struct {
|
||||
DatabaseID uuid.UUID `json:"databaseId" gorm:"column:database_id;type:uuid;primaryKey;not null"`
|
||||
|
||||
MaxBackupSizeMB int64 `json:"maxBackupSizeMb" gorm:"column:max_backup_size_mb;type:int;not null"`
|
||||
MaxBackupsTotalSizeMB int64 `json:"maxBackupsTotalSizeMb" gorm:"column:max_backups_total_size_mb;type:int;not null"`
|
||||
MaxStoragePeriod period.Period `json:"maxStoragePeriod" gorm:"column:max_storage_period;type:text;not null"`
|
||||
}
|
||||
|
||||
func (p *DatabasePlan) TableName() string {
|
||||
return "database_plans"
|
||||
}
|
||||
27
backend/internal/features/plan/repository.go
Normal file
27
backend/internal/features/plan/repository.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package plans
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DatabasePlanRepository struct{}
|
||||
|
||||
func (r *DatabasePlanRepository) GetDatabasePlan(databaseID uuid.UUID) (*DatabasePlan, error) {
|
||||
var databasePlan DatabasePlan
|
||||
|
||||
if err := storage.GetDb().Where("database_id = ?", databaseID).First(&databasePlan).Error; err != nil {
|
||||
if err.Error() == "record not found" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &databasePlan, nil
|
||||
}
|
||||
|
||||
func (r *DatabasePlanRepository) CreateDatabasePlan(databasePlan *DatabasePlan) error {
|
||||
return storage.GetDb().Create(&databasePlan).Error
|
||||
}
|
||||
67
backend/internal/features/plan/service.go
Normal file
67
backend/internal/features/plan/service.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package plans
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/config"
|
||||
"databasus-backend/internal/util/period"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DatabasePlanService struct {
|
||||
databasePlanRepository *DatabasePlanRepository
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *DatabasePlanService) GetDatabasePlan(databaseID uuid.UUID) (*DatabasePlan, error) {
|
||||
plan, err := s.databasePlanRepository.GetDatabasePlan(databaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if plan == nil {
|
||||
s.logger.Info("no database plan found, creating default plan", "databaseID", databaseID)
|
||||
|
||||
defaultPlan := s.createDefaultDatabasePlan(databaseID)
|
||||
|
||||
err := s.databasePlanRepository.CreateDatabasePlan(defaultPlan)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to create default database plan", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return defaultPlan, nil
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (s *DatabasePlanService) createDefaultDatabasePlan(databaseID uuid.UUID) *DatabasePlan {
|
||||
var plan DatabasePlan
|
||||
|
||||
isCloud := config.GetEnv().IsCloud
|
||||
if isCloud {
|
||||
s.logger.Info("creating default database plan for cloud", "databaseID", databaseID)
|
||||
|
||||
// for playground we set limited storages enough to test,
|
||||
// but not too expensive to provide it for Databasus
|
||||
plan = DatabasePlan{
|
||||
DatabaseID: databaseID,
|
||||
MaxBackupSizeMB: 100, // ~ 1.5GB database
|
||||
MaxBackupsTotalSizeMB: 4000, // ~ 30 daily backups + 10 manual backups
|
||||
MaxStoragePeriod: period.PeriodWeek,
|
||||
}
|
||||
} else {
|
||||
s.logger.Info("creating default database plan for self hosted", "databaseID", databaseID)
|
||||
|
||||
// by default - everything is unlimited in self hosted mode
|
||||
plan = DatabasePlan{
|
||||
DatabaseID: databaseID,
|
||||
MaxBackupSizeMB: 0,
|
||||
MaxBackupsTotalSizeMB: 0,
|
||||
MaxStoragePeriod: period.PeriodForever,
|
||||
}
|
||||
}
|
||||
|
||||
return &plan
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package restores
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/config"
|
||||
audit_logs "databasus-backend/internal/features/audit_logs"
|
||||
"databasus-backend/internal/features/backups/backups"
|
||||
backups_core "databasus-backend/internal/features/backups/backups/core"
|
||||
@@ -127,6 +128,13 @@ func (s *RestoreService) RestoreBackupWithAuth(
|
||||
return err
|
||||
}
|
||||
|
||||
if config.GetEnv().IsCloud {
|
||||
// in cloud mode we use only single thread mode,
|
||||
// because otherwise we will exhaust local storage
|
||||
// space (instead of streaming from S3 directly to DB)
|
||||
requestDTO.PostgresqlDatabase.CpuCount = 1
|
||||
}
|
||||
|
||||
if err := s.validateVersionCompatibility(backupDatabase, requestDTO); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ func (uc *RestorePostgresqlBackupUsecase) Execute(
|
||||
return fmt.Errorf("target database name is required for pg_restore")
|
||||
}
|
||||
|
||||
// Validate CPU count constraint for cloud environments
|
||||
if config.GetEnv().IsCloud && pg.CpuCount > 1 {
|
||||
return fmt.Errorf(
|
||||
"parallel restore (CPU count > 1) is not supported in cloud mode due to storage constraints. Please use CPU count = 1",
|
||||
)
|
||||
}
|
||||
|
||||
pgBin := tools.GetPostgresqlExecutable(
|
||||
pg.Version,
|
||||
"pg_restore",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func Test_SaveNewStorage_StorageReturnedViaGet(t *testing.T) {
|
||||
|
||||
assert.Contains(t, storages, savedStorage)
|
||||
|
||||
deleteStorage(t, router, savedStorage.ID, workspace.ID, owner.Token)
|
||||
deleteStorage(t, router, savedStorage.ID, owner.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
@@ -122,7 +122,169 @@ func Test_UpdateExistingStorage_UpdatedStorageReturnedViaGet(t *testing.T) {
|
||||
assert.Equal(t, updatedName, updatedStorage.Name)
|
||||
assert.Equal(t, savedStorage.ID, updatedStorage.ID)
|
||||
|
||||
deleteStorage(t, router, updatedStorage.ID, workspace.ID, owner.Token)
|
||||
deleteStorage(t, router, updatedStorage.ID, owner.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_CreateSystemStorage_OnlyAdminCanCreate_MemberGetsForbidden(t *testing.T) {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
member := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
router := createRouter()
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", admin, router)
|
||||
|
||||
// Admin can create system storage
|
||||
systemStorage := createNewStorage(workspace.ID)
|
||||
systemStorage.IsSystem = true
|
||||
|
||||
var savedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*systemStorage,
|
||||
http.StatusOK,
|
||||
&savedStorage,
|
||||
)
|
||||
|
||||
assert.True(t, savedStorage.IsSystem)
|
||||
assert.Equal(t, systemStorage.Name, savedStorage.Name)
|
||||
|
||||
// Member cannot create system storage
|
||||
memberSystemStorage := createNewStorage(workspace.ID)
|
||||
memberSystemStorage.IsSystem = true
|
||||
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+member.Token,
|
||||
*memberSystemStorage,
|
||||
http.StatusForbidden,
|
||||
)
|
||||
assert.Contains(t, string(resp.Body), "insufficient permissions")
|
||||
|
||||
deleteStorage(t, router, savedStorage.ID, admin.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_UpdateStorageIsSystem_OnlyAdminCanUpdate_MemberGetsForbidden(t *testing.T) {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
member := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
router := createRouter()
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", admin, router)
|
||||
|
||||
// Create a regular storage
|
||||
storage := createNewStorage(workspace.ID)
|
||||
storage.IsSystem = false
|
||||
|
||||
var savedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*storage,
|
||||
http.StatusOK,
|
||||
&savedStorage,
|
||||
)
|
||||
|
||||
assert.False(t, savedStorage.IsSystem)
|
||||
|
||||
// Admin can update to system
|
||||
savedStorage.IsSystem = true
|
||||
var updatedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
savedStorage,
|
||||
http.StatusOK,
|
||||
&updatedStorage,
|
||||
)
|
||||
|
||||
assert.True(t, updatedStorage.IsSystem)
|
||||
|
||||
// Member cannot update system storage
|
||||
updatedStorage.Name = "Updated by member"
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+member.Token,
|
||||
updatedStorage,
|
||||
http.StatusForbidden,
|
||||
)
|
||||
assert.Contains(t, string(resp.Body), "insufficient permissions")
|
||||
|
||||
deleteStorage(t, router, updatedStorage.ID, admin.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_UpdateSystemStorage_CannotChangeToPrivate_ReturnsBadRequest(t *testing.T) {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
router := createRouter()
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", admin, router)
|
||||
|
||||
// Create system storage
|
||||
storage := createNewStorage(workspace.ID)
|
||||
storage.IsSystem = true
|
||||
|
||||
var savedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*storage,
|
||||
http.StatusOK,
|
||||
&savedStorage,
|
||||
)
|
||||
|
||||
assert.True(t, savedStorage.IsSystem)
|
||||
|
||||
// Attempt to change system storage to non-system (should fail)
|
||||
savedStorage.IsSystem = false
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
savedStorage,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
assert.Contains(t, string(resp.Body), "system storage cannot be changed to non-system")
|
||||
|
||||
// Verify storage is still system
|
||||
var retrievedStorage Storage
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()),
|
||||
"Bearer "+admin.Token,
|
||||
http.StatusOK,
|
||||
&retrievedStorage,
|
||||
)
|
||||
assert.True(t, retrievedStorage.IsSystem)
|
||||
|
||||
// Admin can update other fields while keeping IsSystem=true
|
||||
savedStorage.IsSystem = true
|
||||
savedStorage.Name = "Updated System Storage"
|
||||
var updatedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
savedStorage,
|
||||
http.StatusOK,
|
||||
&updatedStorage,
|
||||
)
|
||||
assert.True(t, updatedStorage.IsSystem)
|
||||
assert.Equal(t, "Updated System Storage", updatedStorage.Name)
|
||||
|
||||
deleteStorage(t, router, updatedStorage.ID, admin.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
@@ -205,7 +367,7 @@ func Test_TestExistingStorageConnection_ConnectionEstablished(t *testing.T) {
|
||||
|
||||
assert.Contains(t, string(response.Body), "successful")
|
||||
|
||||
deleteStorage(t, router, savedStorage.ID, workspace.ID, owner.Token)
|
||||
deleteStorage(t, router, savedStorage.ID, owner.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
@@ -301,7 +463,14 @@ func Test_WorkspaceRolePermissions(t *testing.T) {
|
||||
fmt.Sprintf("/api/v1/storages?workspace_id=%s", workspace.ID.String()),
|
||||
"Bearer "+testUserToken, http.StatusOK, &storages,
|
||||
)
|
||||
assert.Len(t, storages, 1)
|
||||
// Count only non-system storages for this workspace
|
||||
nonSystemStorages := 0
|
||||
for _, s := range storages {
|
||||
if !s.IsSystem {
|
||||
nonSystemStorages++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, nonSystemStorages)
|
||||
|
||||
// Test CREATE storage
|
||||
createStatusCode := http.StatusOK
|
||||
@@ -356,16 +525,514 @@ func Test_WorkspaceRolePermissions(t *testing.T) {
|
||||
|
||||
// Cleanup
|
||||
if tt.canCreate {
|
||||
deleteStorage(t, router, savedStorage.ID, workspace.ID, owner.Token)
|
||||
deleteStorage(t, router, savedStorage.ID, owner.Token)
|
||||
}
|
||||
if !tt.canDelete {
|
||||
deleteStorage(t, router, ownerStorage.ID, workspace.ID, owner.Token)
|
||||
deleteStorage(t, router, ownerStorage.ID, owner.Token)
|
||||
}
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SystemStorage_AdminOnlyOperations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
isAdmin bool
|
||||
expectSuccess bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "admin can create system storage",
|
||||
operation: "create",
|
||||
isAdmin: true,
|
||||
expectSuccess: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "member cannot create system storage",
|
||||
operation: "create",
|
||||
isAdmin: false,
|
||||
expectSuccess: false,
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "admin can update storage to make it system",
|
||||
operation: "update_to_system",
|
||||
isAdmin: true,
|
||||
expectSuccess: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "member cannot update storage to make it system",
|
||||
operation: "update_to_system",
|
||||
isAdmin: false,
|
||||
expectSuccess: false,
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "admin can update system storage",
|
||||
operation: "update_system",
|
||||
isAdmin: true,
|
||||
expectSuccess: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "member cannot update system storage",
|
||||
operation: "update_system",
|
||||
isAdmin: false,
|
||||
expectSuccess: false,
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "admin can delete system storage",
|
||||
operation: "delete",
|
||||
isAdmin: true,
|
||||
expectSuccess: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "member cannot delete system storage",
|
||||
operation: "delete",
|
||||
isAdmin: false,
|
||||
expectSuccess: false,
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := createRouter()
|
||||
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
testUserToken = admin.Token
|
||||
} else {
|
||||
member := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspaces_testing.AddMemberToWorkspace(
|
||||
workspace,
|
||||
member,
|
||||
users_enums.WorkspaceRoleMember,
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
testUserToken = member.Token
|
||||
}
|
||||
|
||||
switch tt.operation {
|
||||
case "create":
|
||||
systemStorage := &Storage{
|
||||
WorkspaceID: workspace.ID,
|
||||
Type: StorageTypeLocal,
|
||||
Name: "Test System Storage " + uuid.New().String(),
|
||||
IsSystem: true,
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
|
||||
if tt.expectSuccess {
|
||||
var savedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+testUserToken,
|
||||
*systemStorage,
|
||||
tt.expectedStatus,
|
||||
&savedStorage,
|
||||
)
|
||||
assert.NotEmpty(t, savedStorage.ID)
|
||||
assert.True(t, savedStorage.IsSystem)
|
||||
deleteStorage(t, router, savedStorage.ID, testUserToken)
|
||||
} else {
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+testUserToken,
|
||||
*systemStorage,
|
||||
tt.expectedStatus,
|
||||
)
|
||||
assert.Contains(t, string(resp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
case "update_to_system":
|
||||
// Owner creates private storage first
|
||||
privateStorage := createNewStorage(workspace.ID)
|
||||
var savedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+owner.Token,
|
||||
*privateStorage,
|
||||
http.StatusOK,
|
||||
&savedStorage,
|
||||
)
|
||||
|
||||
// Test user attempts to make it system
|
||||
savedStorage.IsSystem = true
|
||||
if tt.expectSuccess {
|
||||
var updatedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+testUserToken,
|
||||
savedStorage,
|
||||
tt.expectedStatus,
|
||||
&updatedStorage,
|
||||
)
|
||||
assert.True(t, updatedStorage.IsSystem)
|
||||
deleteStorage(t, router, savedStorage.ID, testUserToken)
|
||||
} else {
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+testUserToken,
|
||||
savedStorage,
|
||||
tt.expectedStatus,
|
||||
)
|
||||
assert.Contains(t, string(resp.Body), "insufficient permissions")
|
||||
deleteStorage(t, router, savedStorage.ID, owner.Token)
|
||||
}
|
||||
|
||||
case "update_system":
|
||||
// Admin creates system storage first
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
systemStorage := &Storage{
|
||||
WorkspaceID: workspace.ID,
|
||||
Type: StorageTypeLocal,
|
||||
Name: "Test System Storage " + uuid.New().String(),
|
||||
IsSystem: true,
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
var savedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*systemStorage,
|
||||
http.StatusOK,
|
||||
&savedStorage,
|
||||
)
|
||||
|
||||
// Test user attempts to update system storage
|
||||
savedStorage.Name = "Updated System Storage " + uuid.New().String()
|
||||
if tt.expectSuccess {
|
||||
var updatedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+testUserToken,
|
||||
savedStorage,
|
||||
tt.expectedStatus,
|
||||
&updatedStorage,
|
||||
)
|
||||
assert.Equal(t, savedStorage.Name, updatedStorage.Name)
|
||||
assert.True(t, updatedStorage.IsSystem)
|
||||
deleteStorage(t, router, savedStorage.ID, testUserToken)
|
||||
} else {
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+testUserToken,
|
||||
savedStorage,
|
||||
tt.expectedStatus,
|
||||
)
|
||||
assert.Contains(t, string(resp.Body), "insufficient permissions")
|
||||
deleteStorage(t, router, savedStorage.ID, admin.Token)
|
||||
}
|
||||
|
||||
case "delete":
|
||||
// Admin creates system storage first
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
systemStorage := &Storage{
|
||||
WorkspaceID: workspace.ID,
|
||||
Type: StorageTypeLocal,
|
||||
Name: "Test System Storage " + uuid.New().String(),
|
||||
IsSystem: true,
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
var savedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*systemStorage,
|
||||
http.StatusOK,
|
||||
&savedStorage,
|
||||
)
|
||||
|
||||
// Test user attempts to delete system storage
|
||||
if tt.expectSuccess {
|
||||
test_utils.MakeDeleteRequest(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()),
|
||||
"Bearer "+testUserToken,
|
||||
tt.expectedStatus,
|
||||
)
|
||||
} else {
|
||||
resp := test_utils.MakeDeleteRequest(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()),
|
||||
"Bearer "+testUserToken,
|
||||
tt.expectedStatus,
|
||||
)
|
||||
assert.Contains(t, string(resp.Body), "insufficient permissions")
|
||||
deleteStorage(t, router, savedStorage.ID, admin.Token)
|
||||
}
|
||||
}
|
||||
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GetStorages_SystemStorageIncludedForAllUsers(t *testing.T) {
|
||||
router := createRouter()
|
||||
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
|
||||
|
||||
// Create two workspaces with different owners
|
||||
ownerA := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
ownerB := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspaceA := workspaces_testing.CreateTestWorkspace("Workspace A", ownerA, router)
|
||||
workspaceB := workspaces_testing.CreateTestWorkspace("Workspace B", ownerB, router)
|
||||
|
||||
// Create private storage in workspace A
|
||||
privateStorageA := createNewStorage(workspaceA.ID)
|
||||
var savedPrivateStorageA Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+ownerA.Token,
|
||||
*privateStorageA,
|
||||
http.StatusOK,
|
||||
&savedPrivateStorageA,
|
||||
)
|
||||
|
||||
// Admin creates system storage in workspace B
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
systemStorageB := &Storage{
|
||||
WorkspaceID: workspaceB.ID,
|
||||
Type: StorageTypeLocal,
|
||||
Name: "Test System Storage B " + uuid.New().String(),
|
||||
IsSystem: true,
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
var savedSystemStorageB Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*systemStorageB,
|
||||
http.StatusOK,
|
||||
&savedSystemStorageB,
|
||||
)
|
||||
|
||||
// Test: User from workspace A should see both private storage A and system storage B
|
||||
var storagesForWorkspaceA []Storage
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages?workspace_id=%s", workspaceA.ID.String()),
|
||||
"Bearer "+ownerA.Token,
|
||||
http.StatusOK,
|
||||
&storagesForWorkspaceA,
|
||||
)
|
||||
|
||||
assert.GreaterOrEqual(t, len(storagesForWorkspaceA), 2)
|
||||
foundPrivateA := false
|
||||
foundSystemB := false
|
||||
for _, s := range storagesForWorkspaceA {
|
||||
if s.ID == savedPrivateStorageA.ID {
|
||||
foundPrivateA = true
|
||||
}
|
||||
if s.ID == savedSystemStorageB.ID {
|
||||
foundSystemB = true
|
||||
}
|
||||
}
|
||||
assert.True(t, foundPrivateA, "User from workspace A should see private storage A")
|
||||
assert.True(t, foundSystemB, "User from workspace A should see system storage B")
|
||||
|
||||
// Test: User from workspace B should see system storage B
|
||||
var storagesForWorkspaceB []Storage
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages?workspace_id=%s", workspaceB.ID.String()),
|
||||
"Bearer "+ownerB.Token,
|
||||
http.StatusOK,
|
||||
&storagesForWorkspaceB,
|
||||
)
|
||||
|
||||
assert.GreaterOrEqual(t, len(storagesForWorkspaceB), 1)
|
||||
foundSystemBInWorkspaceB := false
|
||||
for _, s := range storagesForWorkspaceB {
|
||||
if s.ID == savedSystemStorageB.ID {
|
||||
foundSystemBInWorkspaceB = true
|
||||
}
|
||||
// Should NOT see private storage from workspace A
|
||||
assert.NotEqual(
|
||||
t,
|
||||
savedPrivateStorageA.ID,
|
||||
s.ID,
|
||||
"User from workspace B should not see private storage from workspace A",
|
||||
)
|
||||
}
|
||||
assert.True(t, foundSystemBInWorkspaceB, "User from workspace B should see system storage B")
|
||||
|
||||
// Test: Outsider (not in any workspace) cannot access storages
|
||||
outsider := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
test_utils.MakeGetRequest(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages?workspace_id=%s", workspaceA.ID.String()),
|
||||
"Bearer "+outsider.Token,
|
||||
http.StatusForbidden,
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
deleteStorage(t, router, savedPrivateStorageA.ID, ownerA.Token)
|
||||
deleteStorage(t, router, savedSystemStorageB.ID, admin.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspaceA, router)
|
||||
workspaces_testing.RemoveTestWorkspace(workspaceB, router)
|
||||
}
|
||||
|
||||
func Test_GetSystemStorage_SensitiveDataHiddenForNonAdmin(t *testing.T) {
|
||||
router := createRouter()
|
||||
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
|
||||
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
member := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", member, router)
|
||||
|
||||
// Admin creates system S3 storage with credentials
|
||||
systemS3Storage := &Storage{
|
||||
WorkspaceID: workspace.ID,
|
||||
Type: StorageTypeS3,
|
||||
Name: "Test System S3 Storage " + uuid.New().String(),
|
||||
IsSystem: true,
|
||||
S3Storage: &s3_storage.S3Storage{
|
||||
S3Bucket: "test-system-bucket",
|
||||
S3Region: "us-east-1",
|
||||
S3AccessKey: "test-access-key-123",
|
||||
S3SecretKey: "test-secret-key-456",
|
||||
S3Endpoint: "https://s3.amazonaws.com",
|
||||
},
|
||||
}
|
||||
|
||||
var savedStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*systemS3Storage,
|
||||
http.StatusOK,
|
||||
&savedStorage,
|
||||
)
|
||||
|
||||
assert.NotEmpty(t, savedStorage.ID)
|
||||
assert.True(t, savedStorage.IsSystem)
|
||||
|
||||
// Test: Admin retrieves system storage - should see S3Storage object with hidden sensitive fields
|
||||
var adminView Storage
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()),
|
||||
"Bearer "+admin.Token,
|
||||
http.StatusOK,
|
||||
&adminView,
|
||||
)
|
||||
|
||||
assert.NotNil(t, adminView.S3Storage, "Admin should see S3Storage object")
|
||||
assert.Equal(t, "test-system-bucket", adminView.S3Storage.S3Bucket)
|
||||
assert.Equal(t, "us-east-1", adminView.S3Storage.S3Region)
|
||||
// Sensitive fields should be hidden (empty strings)
|
||||
assert.Equal(
|
||||
t,
|
||||
"",
|
||||
adminView.S3Storage.S3AccessKey,
|
||||
"Admin should see hidden (empty) access key",
|
||||
)
|
||||
assert.Equal(
|
||||
t,
|
||||
"",
|
||||
adminView.S3Storage.S3SecretKey,
|
||||
"Admin should see hidden (empty) secret key",
|
||||
)
|
||||
|
||||
// Test: Member retrieves system storage - should see storage but all specific data hidden
|
||||
var memberView Storage
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()),
|
||||
"Bearer "+member.Token,
|
||||
http.StatusOK,
|
||||
&memberView,
|
||||
)
|
||||
|
||||
assert.Equal(t, savedStorage.ID, memberView.ID)
|
||||
assert.Equal(t, savedStorage.Name, memberView.Name)
|
||||
assert.True(t, memberView.IsSystem)
|
||||
|
||||
// All storage type objects should be nil for non-admin viewing system storage
|
||||
assert.Nil(t, memberView.S3Storage, "Non-admin should not see S3Storage object")
|
||||
assert.Nil(t, memberView.LocalStorage, "Non-admin should not see LocalStorage object")
|
||||
assert.Nil(
|
||||
t,
|
||||
memberView.GoogleDriveStorage,
|
||||
"Non-admin should not see GoogleDriveStorage object",
|
||||
)
|
||||
assert.Nil(t, memberView.NASStorage, "Non-admin should not see NASStorage object")
|
||||
assert.Nil(t, memberView.AzureBlobStorage, "Non-admin should not see AzureBlobStorage object")
|
||||
assert.Nil(t, memberView.FTPStorage, "Non-admin should not see FTPStorage object")
|
||||
assert.Nil(t, memberView.SFTPStorage, "Non-admin should not see SFTPStorage object")
|
||||
assert.Nil(t, memberView.RcloneStorage, "Non-admin should not see RcloneStorage object")
|
||||
|
||||
// Test: Member can also see system storage in GetStorages list
|
||||
var storages []Storage
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages?workspace_id=%s", workspace.ID.String()),
|
||||
"Bearer "+member.Token,
|
||||
http.StatusOK,
|
||||
&storages,
|
||||
)
|
||||
|
||||
foundSystemStorage := false
|
||||
for _, s := range storages {
|
||||
if s.ID == savedStorage.ID {
|
||||
foundSystemStorage = true
|
||||
assert.True(t, s.IsSystem)
|
||||
assert.Nil(t, s.S3Storage, "Non-admin should not see S3Storage in list")
|
||||
}
|
||||
}
|
||||
assert.True(t, foundSystemStorage, "System storage should be in list")
|
||||
|
||||
// Cleanup
|
||||
deleteStorage(t, router, savedStorage.ID, admin.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_UserNotInWorkspace_CannotAccessStorages(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
outsider := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
@@ -417,7 +1084,7 @@ func Test_UserNotInWorkspace_CannotAccessStorages(t *testing.T) {
|
||||
http.StatusForbidden,
|
||||
)
|
||||
|
||||
deleteStorage(t, router, savedStorage.ID, workspace.ID, owner.Token)
|
||||
deleteStorage(t, router, savedStorage.ID, owner.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
@@ -450,7 +1117,7 @@ func Test_CrossWorkspaceSecurity_CannotAccessStorageFromAnotherWorkspace(t *test
|
||||
)
|
||||
assert.Contains(t, string(response.Body), "insufficient permissions")
|
||||
|
||||
deleteStorage(t, router, savedStorage.ID, workspace1.ID, owner1.Token)
|
||||
deleteStorage(t, router, savedStorage.ID, owner1.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
}
|
||||
@@ -986,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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1122,10 +1793,10 @@ func Test_TransferStorage_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
assert.Equal(t, targetWorkspace.ID, retrievedStorage.WorkspaceID)
|
||||
|
||||
deleteStorage(t, router, savedStorage.ID, targetWorkspace.ID, targetOwner.Token)
|
||||
deleteStorage(t, router, savedStorage.ID, targetOwner.Token)
|
||||
} else {
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
deleteStorage(t, router, savedStorage.ID, sourceWorkspace.ID, sourceOwner.Token)
|
||||
deleteStorage(t, router, savedStorage.ID, sourceOwner.Token)
|
||||
}
|
||||
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
@@ -1175,11 +1846,129 @@ func Test_TransferStorageNotManagableWorkspace_TransferFailed(t *testing.T) {
|
||||
"insufficient permissions to manage storage in target workspace",
|
||||
)
|
||||
|
||||
deleteStorage(t, router, savedStorage.ID, workspace1.ID, userA.Token)
|
||||
deleteStorage(t, router, savedStorage.ID, userA.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
}
|
||||
|
||||
func Test_TransferSystemStorage_TransferBlocked(t *testing.T) {
|
||||
router := createRouter()
|
||||
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
|
||||
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
workspaceA := workspaces_testing.CreateTestWorkspace("Workspace A", admin, router)
|
||||
workspaceB := workspaces_testing.CreateTestWorkspace("Workspace B", admin, router)
|
||||
|
||||
// Admin creates system storage in workspace A
|
||||
systemStorage := &Storage{
|
||||
WorkspaceID: workspaceA.ID,
|
||||
Type: StorageTypeLocal,
|
||||
Name: "Test System Storage " + uuid.New().String(),
|
||||
IsSystem: true,
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
var savedSystemStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*systemStorage,
|
||||
http.StatusOK,
|
||||
&savedSystemStorage,
|
||||
)
|
||||
|
||||
// Admin attempts to transfer system storage to workspace B - should be blocked
|
||||
transferRequest := TransferStorageRequest{
|
||||
TargetWorkspaceID: workspaceB.ID,
|
||||
}
|
||||
|
||||
testResp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages/%s/transfer", savedSystemStorage.ID.String()),
|
||||
"Bearer "+admin.Token,
|
||||
transferRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(
|
||||
t,
|
||||
string(testResp.Body),
|
||||
"system storage cannot be transferred",
|
||||
"Transfer should fail with appropriate error message",
|
||||
)
|
||||
|
||||
// Verify storage is still in workspace A
|
||||
var retrievedStorage Storage
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages/%s", savedSystemStorage.ID.String()),
|
||||
"Bearer "+admin.Token,
|
||||
http.StatusOK,
|
||||
&retrievedStorage,
|
||||
)
|
||||
assert.Equal(
|
||||
t,
|
||||
workspaceA.ID,
|
||||
retrievedStorage.WorkspaceID,
|
||||
"Storage should remain in workspace A",
|
||||
)
|
||||
|
||||
// Test regression: Non-system storage can still be transferred
|
||||
privateStorage := createNewStorage(workspaceA.ID)
|
||||
var savedPrivateStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*privateStorage,
|
||||
http.StatusOK,
|
||||
&savedPrivateStorage,
|
||||
)
|
||||
|
||||
privateTransferResp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages/%s/transfer", savedPrivateStorage.ID.String()),
|
||||
"Bearer "+admin.Token,
|
||||
transferRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
assert.Contains(
|
||||
t,
|
||||
string(privateTransferResp.Body),
|
||||
"transferred successfully",
|
||||
"Private storage should be transferable",
|
||||
)
|
||||
|
||||
// Verify private storage was transferred to workspace B
|
||||
var transferredStorage Storage
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/storages/%s", savedPrivateStorage.ID.String()),
|
||||
"Bearer "+admin.Token,
|
||||
http.StatusOK,
|
||||
&transferredStorage,
|
||||
)
|
||||
assert.Equal(
|
||||
t,
|
||||
workspaceB.ID,
|
||||
transferredStorage.WorkspaceID,
|
||||
"Private storage should be in workspace B",
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
deleteStorage(t, router, savedSystemStorage.ID, admin.Token)
|
||||
deleteStorage(t, router, savedPrivateStorage.ID, admin.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspaceA, router)
|
||||
workspaces_testing.RemoveTestWorkspace(workspaceB, router)
|
||||
}
|
||||
|
||||
func createRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
@@ -1212,12 +2001,13 @@ func verifyStorageData(t *testing.T, expected *Storage, actual *Storage) {
|
||||
assert.Equal(t, expected.Name, actual.Name)
|
||||
assert.Equal(t, expected.Type, actual.Type)
|
||||
assert.Equal(t, expected.WorkspaceID, actual.WorkspaceID)
|
||||
assert.Equal(t, expected.IsSystem, actual.IsSystem)
|
||||
}
|
||||
|
||||
func deleteStorage(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
storageID, workspaceID uuid.UUID,
|
||||
storageID uuid.UUID,
|
||||
token string,
|
||||
) {
|
||||
test_utils.MakeDeleteRequest(
|
||||
|
||||
@@ -33,4 +33,13 @@ var (
|
||||
ErrStorageHasOtherAttachedDatabasesCannotTransfer = errors.New(
|
||||
"storage has other attached databases and cannot be transferred",
|
||||
)
|
||||
ErrSystemStorageCannotBeTransferred = errors.New(
|
||||
"system storage cannot be transferred between workspaces",
|
||||
)
|
||||
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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ type Storage struct {
|
||||
Type StorageType `json:"type" gorm:"column:type;not null;type:text"`
|
||||
Name string `json:"name" gorm:"column:name;not null;type:text"`
|
||||
LastSaveError *string `json:"lastSaveError" gorm:"column:last_save_error;type:text"`
|
||||
IsSystem bool `json:"isSystem" gorm:"column:is_system;not null;default:false"`
|
||||
|
||||
// specific storage
|
||||
LocalStorage *local_storage.LocalStorage `json:"localStorage" gorm:"foreignKey:StorageID"`
|
||||
@@ -86,6 +87,17 @@ func (s *Storage) HideSensitiveData() {
|
||||
s.getSpecificStorage().HideSensitiveData()
|
||||
}
|
||||
|
||||
func (s *Storage) HideAllData() {
|
||||
s.LocalStorage = nil
|
||||
s.S3Storage = nil
|
||||
s.GoogleDriveStorage = nil
|
||||
s.NASStorage = nil
|
||||
s.AzureBlobStorage = nil
|
||||
s.FTPStorage = nil
|
||||
s.SFTPStorage = nil
|
||||
s.RcloneStorage = nil
|
||||
}
|
||||
|
||||
func (s *Storage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
return s.getSpecificStorage().EncryptSensitiveData(encryptor)
|
||||
}
|
||||
@@ -93,6 +105,7 @@ func (s *Storage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) erro
|
||||
func (s *Storage) Update(incoming *Storage) {
|
||||
s.Name = incoming.Name
|
||||
s.Type = incoming.Type
|
||||
s.IsSystem = incoming.IsSystem
|
||||
|
||||
switch s.Type {
|
||||
case StorageTypeLocal:
|
||||
|
||||
@@ -165,7 +165,7 @@ func (r *StorageRepository) FindByWorkspaceID(workspaceID uuid.UUID) ([]*Storage
|
||||
Preload("FTPStorage").
|
||||
Preload("SFTPStorage").
|
||||
Preload("RcloneStorage").
|
||||
Where("workspace_id = ?", workspaceID).
|
||||
Where("workspace_id = ? OR is_system = TRUE", workspaceID).
|
||||
Order("name ASC").
|
||||
Find(&storages).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -3,7 +3,9 @@ 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"
|
||||
workspaces_services "databasus-backend/internal/features/workspaces/services"
|
||||
"databasus-backend/internal/util/encryption"
|
||||
@@ -36,8 +38,18 @@ 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 {
|
||||
// only admin can manage system storage
|
||||
return ErrInsufficientPermissionsToManageStorage
|
||||
}
|
||||
|
||||
if isUpdate {
|
||||
existingStorage, err := s.storageRepository.FindByID(storage.ID)
|
||||
if err != nil {
|
||||
@@ -48,6 +60,10 @@ func (s *StorageService) SaveStorage(
|
||||
return ErrStorageDoesNotBelongToWorkspace
|
||||
}
|
||||
|
||||
if existingStorage.IsSystem && !storage.IsSystem {
|
||||
return ErrSystemStorageCannotBeMadePrivate
|
||||
}
|
||||
|
||||
existingStorage.Update(storage)
|
||||
|
||||
if err := existingStorage.EncryptSensitiveData(s.fieldEncryptor); err != nil {
|
||||
@@ -111,6 +127,11 @@ func (s *StorageService) DeleteStorage(
|
||||
return ErrInsufficientPermissionsToManageStorage
|
||||
}
|
||||
|
||||
if storage.IsSystem && user.Role != users_enums.UserRoleAdmin {
|
||||
// only admin can manage system storage
|
||||
return ErrInsufficientPermissionsToManageStorage
|
||||
}
|
||||
|
||||
attachedDatabasesIDs, err := s.storageDatabaseCounter.GetStorageAttachedDatabasesIDs(storage.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -142,16 +163,22 @@ func (s *StorageService) GetStorage(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
canView, _, err := s.workspaceService.CanUserAccessWorkspace(storage.WorkspaceID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canView {
|
||||
return nil, ErrInsufficientPermissionsToViewStorage
|
||||
if !storage.IsSystem {
|
||||
canView, _, err := s.workspaceService.CanUserAccessWorkspace(storage.WorkspaceID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canView {
|
||||
return nil, ErrInsufficientPermissionsToViewStorage
|
||||
}
|
||||
}
|
||||
|
||||
storage.HideSensitiveData()
|
||||
|
||||
if storage.IsSystem && user.Role != users_enums.UserRoleAdmin {
|
||||
storage.HideAllData()
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
@@ -174,6 +201,10 @@ func (s *StorageService) GetStorages(
|
||||
|
||||
for _, storage := range storages {
|
||||
storage.HideSensitiveData()
|
||||
|
||||
if storage.IsSystem && user.Role != users_enums.UserRoleAdmin {
|
||||
storage.HideAllData()
|
||||
}
|
||||
}
|
||||
|
||||
return storages, nil
|
||||
@@ -213,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 {
|
||||
@@ -258,6 +295,10 @@ func (s *StorageService) TransferStorageToWorkspace(
|
||||
return err
|
||||
}
|
||||
|
||||
if existingStorage.IsSystem {
|
||||
return ErrSystemStorageCannotBeTransferred
|
||||
}
|
||||
|
||||
canManageSource, err := s.workspaceService.CanUserManageDBs(existingStorage.WorkspaceID, user)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -23,6 +23,18 @@ func (c *HealthcheckController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
// @Failure 503 {object} HealthcheckResponse
|
||||
// @Router /system/health [get]
|
||||
func (c *HealthcheckController) CheckHealth(ctx *gin.Context) {
|
||||
// Allow unrestricted CORS for health check endpoint
|
||||
// This enables monitoring tools from any origin to check system health
|
||||
ctx.Header("Access-Control-Allow-Origin", "*")
|
||||
ctx.Header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
ctx.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if ctx.Request.Method == "OPTIONS" {
|
||||
ctx.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
err := c.healthcheckService.IsHealthy()
|
||||
|
||||
if err == 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -47,3 +47,36 @@ func (p Period) ToDuration() time.Duration {
|
||||
panic("unknown period: " + string(p))
|
||||
}
|
||||
}
|
||||
|
||||
// CompareTo compares this period with another and returns:
|
||||
// -1 if p < other
|
||||
//
|
||||
// 0 if p == other
|
||||
// 1 if p > other
|
||||
//
|
||||
// FOREVER is treated as the longest period
|
||||
func (p Period) CompareTo(other Period) int {
|
||||
if p == other {
|
||||
return 0
|
||||
}
|
||||
|
||||
d1 := p.ToDuration()
|
||||
d2 := other.ToDuration()
|
||||
|
||||
// FOREVER has duration 0, but should be treated as longest period
|
||||
if p == PeriodForever {
|
||||
return 1
|
||||
}
|
||||
if other == PeriodForever {
|
||||
return -1
|
||||
}
|
||||
|
||||
if d1 < d2 {
|
||||
return -1
|
||||
}
|
||||
if d1 > d2 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE database_plans (
|
||||
database_id UUID PRIMARY KEY,
|
||||
max_backup_size_mb BIGINT NOT NULL,
|
||||
max_backups_total_size_mb BIGINT NOT NULL,
|
||||
max_storage_period TEXT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE database_plans
|
||||
ADD CONSTRAINT fk_database_plans_database_id
|
||||
FOREIGN KEY (database_id)
|
||||
REFERENCES databases (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_database_plans_database_id ON database_plans (database_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP INDEX IF EXISTS idx_database_plans_database_id;
|
||||
|
||||
ALTER TABLE database_plans DROP CONSTRAINT IF EXISTS fk_database_plans_database_id;
|
||||
|
||||
DROP TABLE IF EXISTS database_plans;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE storages
|
||||
ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE storages
|
||||
DROP COLUMN is_system;
|
||||
-- +goose StatementEnd
|
||||
@@ -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
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getApplicationServer } from '../../../constants';
|
||||
import RequestOptions from '../../../shared/api/RequestOptions';
|
||||
import { apiHelper } from '../../../shared/api/apiHelper';
|
||||
import type { DatabasePlan } from '../../plan';
|
||||
import type { BackupConfig } from '../model/BackupConfig';
|
||||
import type { TransferDatabaseRequest } from '../model/TransferDatabaseRequest';
|
||||
|
||||
@@ -54,4 +55,12 @@ export const backupConfigApi = {
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
async getDatabasePlan(databaseId: string) {
|
||||
return apiHelper.fetchGetJson<DatabasePlan>(
|
||||
`${getApplicationServer()}/api/v1/backup-configs/database/${databaseId}/plan`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,3 +6,4 @@ export type { BackupConfig } from './model/BackupConfig';
|
||||
export { BackupNotificationType } from './model/BackupNotificationType';
|
||||
export { BackupEncryption } from './model/BackupEncryption';
|
||||
export type { TransferDatabaseRequest } from './model/TransferDatabaseRequest';
|
||||
export type { DatabasePlan } from '../plan';
|
||||
|
||||
1
frontend/src/entity/plan/index.ts
Normal file
1
frontend/src/entity/plan/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { DatabasePlan } from './model/DatabasePlan';
|
||||
8
frontend/src/entity/plan/model/DatabasePlan.ts
Normal file
8
frontend/src/entity/plan/model/DatabasePlan.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Period } from '../../databases/model/Period';
|
||||
|
||||
export interface DatabasePlan {
|
||||
databaseId: string;
|
||||
maxBackupSizeMb: number;
|
||||
maxBackupsTotalSizeMb: number;
|
||||
maxStoragePeriod: Period;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface Storage {
|
||||
name: string;
|
||||
lastSaveError?: string;
|
||||
workspaceId: string;
|
||||
isSystem: boolean;
|
||||
|
||||
// specific storage types
|
||||
localStorage?: LocalStorage;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15,12 +15,19 @@ import { CronExpressionParser } from 'cron-parser';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { type BackupConfig, BackupEncryption, backupConfigApi } from '../../../entity/backups';
|
||||
import { IS_CLOUD } from '../../../constants';
|
||||
import {
|
||||
type BackupConfig,
|
||||
BackupEncryption,
|
||||
type DatabasePlan,
|
||||
backupConfigApi,
|
||||
} from '../../../entity/backups';
|
||||
import { BackupNotificationType } from '../../../entity/backups/model/BackupNotificationType';
|
||||
import type { Database } from '../../../entity/databases';
|
||||
import { Period } from '../../../entity/databases/model/Period';
|
||||
import { type Interval, IntervalType } from '../../../entity/intervals';
|
||||
import { type Storage, getStorageLogoFromType, storageApi } from '../../../entity/storages';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import { getUserTimeFormat } from '../../../shared/time';
|
||||
import {
|
||||
getUserTimeFormat as getIs12Hour,
|
||||
@@ -33,6 +40,7 @@ import { ConfirmationComponent } from '../../../shared/ui';
|
||||
import { EditStorageComponent } from '../../storages/ui/edit/EditStorageComponent';
|
||||
|
||||
interface Props {
|
||||
user: UserProfile;
|
||||
database: Database;
|
||||
|
||||
isShowBackButton: boolean;
|
||||
@@ -57,6 +65,7 @@ const weekdayOptions = [
|
||||
];
|
||||
|
||||
export const EditBackupConfigComponent = ({
|
||||
user,
|
||||
database,
|
||||
|
||||
isShowBackButton,
|
||||
@@ -73,12 +82,14 @@ export const EditBackupConfigComponent = ({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [storages, setStorages] = useState<Storage[]>([]);
|
||||
const [isStoragesLoading, setIsStoragesLoading] = useState(false);
|
||||
const [isShowCreateStorage, setShowCreateStorage] = useState(false);
|
||||
const [storageSelectKey, setStorageSelectKey] = useState(0);
|
||||
|
||||
const [isShowWarn, setIsShowWarn] = useState(false);
|
||||
|
||||
const [databasePlan, setDatabasePlan] = useState<DatabasePlan>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const hasAdvancedValues =
|
||||
!!backupConfig?.isRetryIfFailed ||
|
||||
(backupConfig?.maxBackupSizeMb ?? 0) > 0 ||
|
||||
@@ -92,6 +103,65 @@ export const EditBackupConfigComponent = ({
|
||||
|
||||
const dateTimeFormat = useMemo(() => getUserTimeFormat(), []);
|
||||
|
||||
const createDefaultPlan = (databaseId: string, isCloud: boolean): DatabasePlan => {
|
||||
if (isCloud) {
|
||||
return {
|
||||
databaseId,
|
||||
maxBackupSizeMb: 100,
|
||||
maxBackupsTotalSizeMb: 4000,
|
||||
maxStoragePeriod: Period.WEEK,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
databaseId,
|
||||
maxBackupSizeMb: 0,
|
||||
maxBackupsTotalSizeMb: 0,
|
||||
maxStoragePeriod: Period.FOREVER,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isPeriodAllowed = (period: Period, maxPeriod: Period): boolean => {
|
||||
const periodOrder = [
|
||||
Period.DAY,
|
||||
Period.WEEK,
|
||||
Period.MONTH,
|
||||
Period.THREE_MONTH,
|
||||
Period.SIX_MONTH,
|
||||
Period.YEAR,
|
||||
Period.TWO_YEARS,
|
||||
Period.THREE_YEARS,
|
||||
Period.FOUR_YEARS,
|
||||
Period.FIVE_YEARS,
|
||||
Period.FOREVER,
|
||||
];
|
||||
const periodIndex = periodOrder.indexOf(period);
|
||||
const maxIndex = periodOrder.indexOf(maxPeriod);
|
||||
return periodIndex <= maxIndex;
|
||||
};
|
||||
|
||||
const availablePeriods = useMemo(() => {
|
||||
const allPeriods = [
|
||||
{ label: '1 day', value: Period.DAY },
|
||||
{ label: '1 week', value: Period.WEEK },
|
||||
{ label: '1 month', value: Period.MONTH },
|
||||
{ label: '3 months', value: Period.THREE_MONTH },
|
||||
{ label: '6 months', value: Period.SIX_MONTH },
|
||||
{ label: '1 year', value: Period.YEAR },
|
||||
{ label: '2 years', value: Period.TWO_YEARS },
|
||||
{ label: '3 years', value: Period.THREE_YEARS },
|
||||
{ label: '4 years', value: Period.FOUR_YEARS },
|
||||
{ label: '5 years', value: Period.FIVE_YEARS },
|
||||
{ label: 'Forever', value: Period.FOREVER },
|
||||
];
|
||||
|
||||
if (!databasePlan) {
|
||||
return allPeriods;
|
||||
}
|
||||
|
||||
return allPeriods.filter((p) => isPeriodAllowed(p.value, databasePlan.maxStoragePeriod));
|
||||
}, [databasePlan]);
|
||||
|
||||
const updateBackupConfig = (patch: Partial<BackupConfig>) => {
|
||||
setBackupConfig((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
setIsUnsaved(true);
|
||||
@@ -131,51 +201,70 @@ export const EditBackupConfigComponent = ({
|
||||
};
|
||||
|
||||
const loadStorages = async () => {
|
||||
setIsStoragesLoading(true);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
setIsStoragesLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (database.id) {
|
||||
backupConfigApi.getBackupConfigByDbID(database.id).then((res) => {
|
||||
setBackupConfig(res);
|
||||
setIsUnsaved(false);
|
||||
setIsSaving(false);
|
||||
});
|
||||
} else {
|
||||
setBackupConfig({
|
||||
databaseId: database.id,
|
||||
isBackupsEnabled: true,
|
||||
backupInterval: {
|
||||
id: undefined as unknown as string,
|
||||
interval: IntervalType.DAILY,
|
||||
timeOfDay: '00:00',
|
||||
},
|
||||
storage: undefined,
|
||||
storePeriod: Period.THREE_MONTH,
|
||||
sendNotificationsOn: [BackupNotificationType.BackupFailed],
|
||||
isRetryIfFailed: true,
|
||||
maxFailedTriesCount: 3,
|
||||
encryption: BackupEncryption.ENCRYPTED,
|
||||
const run = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
maxBackupSizeMb: 0,
|
||||
maxBackupsTotalSizeMb: 0,
|
||||
});
|
||||
}
|
||||
loadStorages();
|
||||
try {
|
||||
if (database.id) {
|
||||
const config = await backupConfigApi.getBackupConfigByDbID(database.id);
|
||||
setBackupConfig(config);
|
||||
setIsUnsaved(false);
|
||||
setIsSaving(false);
|
||||
|
||||
const plan = await backupConfigApi.getDatabasePlan(database.id);
|
||||
setDatabasePlan(plan);
|
||||
} else {
|
||||
const plan = createDefaultPlan('', IS_CLOUD);
|
||||
setDatabasePlan(plan);
|
||||
|
||||
setBackupConfig({
|
||||
databaseId: database.id,
|
||||
isBackupsEnabled: true,
|
||||
backupInterval: {
|
||||
id: undefined as unknown as string,
|
||||
interval: IntervalType.DAILY,
|
||||
timeOfDay: '00:00',
|
||||
},
|
||||
storage: undefined,
|
||||
storePeriod:
|
||||
plan.maxStoragePeriod === Period.FOREVER ? Period.THREE_MONTH : plan.maxStoragePeriod,
|
||||
sendNotificationsOn: [BackupNotificationType.BackupFailed],
|
||||
isRetryIfFailed: true,
|
||||
maxFailedTriesCount: 3,
|
||||
encryption: BackupEncryption.ENCRYPTED,
|
||||
maxBackupSizeMb: plan.maxBackupSizeMb,
|
||||
maxBackupsTotalSizeMb: plan.maxBackupsTotalSizeMb,
|
||||
});
|
||||
}
|
||||
|
||||
await loadStorages();
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
}, [database]);
|
||||
|
||||
if (!backupConfig) return <div />;
|
||||
|
||||
if (isStoragesLoading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mb-5 flex items-center">
|
||||
<Spin />
|
||||
@@ -183,6 +272,8 @@ export const EditBackupConfigComponent = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!backupConfig) return <div />;
|
||||
|
||||
const { backupInterval } = backupConfig;
|
||||
|
||||
// UTC → local conversions for display
|
||||
@@ -414,28 +505,30 @@ export const EditBackupConfigComponent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Encryption</div>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={backupConfig.encryption}
|
||||
onChange={(v) => updateBackupConfig({ encryption: v })}
|
||||
size="small"
|
||||
className="w-[200px]"
|
||||
options={[
|
||||
{ label: 'None', value: BackupEncryption.NONE },
|
||||
{ label: 'Encrypt backup files', value: BackupEncryption.ENCRYPTED },
|
||||
]}
|
||||
/>
|
||||
{!IS_CLOUD && (
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Encryption</div>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={backupConfig.encryption}
|
||||
onChange={(v) => updateBackupConfig({ encryption: v })}
|
||||
size="small"
|
||||
className="w-[200px]"
|
||||
options={[
|
||||
{ label: 'None', value: BackupEncryption.NONE },
|
||||
{ label: 'Encrypt backup files', value: BackupEncryption.ENCRYPTED },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Databasus or download them unencrypted via the 'Download' button."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Databasus or download them unencrypted via the 'Download' button."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Store period</div>
|
||||
@@ -445,19 +538,7 @@ export const EditBackupConfigComponent = ({
|
||||
onChange={(v) => updateBackupConfig({ storePeriod: v })}
|
||||
size="small"
|
||||
className="w-[200px]"
|
||||
options={[
|
||||
{ label: '1 day', value: Period.DAY },
|
||||
{ label: '1 week', value: Period.WEEK },
|
||||
{ label: '1 month', value: Period.MONTH },
|
||||
{ label: '3 months', value: Period.THREE_MONTH },
|
||||
{ label: '6 months', value: Period.SIX_MONTH },
|
||||
{ label: '1 year', value: Period.YEAR },
|
||||
{ label: '2 years', value: Period.TWO_YEARS },
|
||||
{ label: '3 years', value: Period.THREE_YEARS },
|
||||
{ label: '4 years', value: Period.FOUR_YEARS },
|
||||
{ label: '5 years', value: Period.FIVE_YEARS },
|
||||
{ label: 'Forever', value: Period.FOREVER },
|
||||
]}
|
||||
options={availablePeriods}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
@@ -559,7 +640,7 @@ export const EditBackupConfigComponent = ({
|
||||
value={backupConfig.maxFailedTriesCount}
|
||||
onChange={(value) => updateBackupConfig({ maxFailedTriesCount: value || 1 })}
|
||||
size="small"
|
||||
className="w-full max-w-[200px] grow"
|
||||
className="w-full max-w-[75px] grow"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
@@ -575,16 +656,16 @@ export const EditBackupConfigComponent = ({
|
||||
<div className="mt-5 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Max backup size limit</div>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
<Switch
|
||||
size="small"
|
||||
checked={backupConfig.maxBackupSizeMb > 0}
|
||||
onChange={(e) => {
|
||||
disabled={IS_CLOUD}
|
||||
onChange={(checked) => {
|
||||
updateBackupConfig({
|
||||
maxBackupSizeMb: e.target.checked ? backupConfig.maxBackupSizeMb || 1000 : 0,
|
||||
maxBackupSizeMb: checked ? backupConfig.maxBackupSizeMb || 1000 : 0,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Enable
|
||||
</Checkbox>
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
@@ -596,19 +677,33 @@ export const EditBackupConfigComponent = ({
|
||||
</div>
|
||||
|
||||
{backupConfig.maxBackupSizeMb > 0 && (
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Max backup size (MB)</div>
|
||||
<div className="mb-5 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Max file size (MB)</div>
|
||||
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={
|
||||
databasePlan?.maxBackupSizeMb && databasePlan.maxBackupSizeMb > 0
|
||||
? databasePlan.maxBackupSizeMb
|
||||
: undefined
|
||||
}
|
||||
value={backupConfig.maxBackupSizeMb}
|
||||
onChange={(value) => updateBackupConfig({ maxBackupSizeMb: value || 1 })}
|
||||
onChange={(value) => {
|
||||
const newValue = value || 1;
|
||||
if (databasePlan?.maxBackupSizeMb && databasePlan.maxBackupSizeMb > 0) {
|
||||
updateBackupConfig({
|
||||
maxBackupSizeMb: Math.min(newValue, databasePlan.maxBackupSizeMb),
|
||||
});
|
||||
} else {
|
||||
updateBackupConfig({ maxBackupSizeMb: newValue });
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[100px] grow"
|
||||
className="w-full max-w-[75px] grow"
|
||||
/>
|
||||
|
||||
<div className="ml-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{(backupConfig.maxBackupSizeMb / 1024).toFixed(2)} GB
|
||||
~{((backupConfig.maxBackupSizeMb / 1024) * 15).toFixed(2)} GB DB size
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -616,22 +711,22 @@ export const EditBackupConfigComponent = ({
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Limit total backups size</div>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
<Switch
|
||||
size="small"
|
||||
checked={backupConfig.maxBackupsTotalSizeMb > 0}
|
||||
onChange={(e) => {
|
||||
disabled={IS_CLOUD}
|
||||
onChange={(checked) => {
|
||||
updateBackupConfig({
|
||||
maxBackupsTotalSizeMb: e.target.checked
|
||||
maxBackupsTotalSizeMb: checked
|
||||
? backupConfig.maxBackupsTotalSizeMb || 1_000_000
|
||||
: 0,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Enable
|
||||
</Checkbox>
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Limits the total size of all backups in storage. Once this limit is exceeded, the oldest backups are automatically removed until the total size is within the limit again."
|
||||
title="Limits the total size of all backups in storage (like S3, local, etc.). Once this limit is exceeded, the oldest backups are automatically removed until the total size is within the limit again."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
@@ -640,17 +735,35 @@ export const EditBackupConfigComponent = ({
|
||||
|
||||
{backupConfig.maxBackupsTotalSizeMb > 0 && (
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Max total size (MB)</div>
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Backups files size (MB)</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={
|
||||
databasePlan?.maxBackupsTotalSizeMb && databasePlan.maxBackupsTotalSizeMb > 0
|
||||
? databasePlan.maxBackupsTotalSizeMb
|
||||
: undefined
|
||||
}
|
||||
value={backupConfig.maxBackupsTotalSizeMb}
|
||||
onChange={(value) => updateBackupConfig({ maxBackupsTotalSizeMb: value || 1 })}
|
||||
onChange={(value) => {
|
||||
const newValue = value || 1;
|
||||
if (
|
||||
databasePlan?.maxBackupsTotalSizeMb &&
|
||||
databasePlan.maxBackupsTotalSizeMb > 0
|
||||
) {
|
||||
updateBackupConfig({
|
||||
maxBackupsTotalSizeMb: Math.min(newValue, databasePlan.maxBackupsTotalSizeMb),
|
||||
});
|
||||
} else {
|
||||
updateBackupConfig({ maxBackupsTotalSizeMb: newValue });
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[100px] grow"
|
||||
className="w-full max-w-[75px] grow"
|
||||
/>
|
||||
|
||||
<div className="ml-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{(backupConfig.maxBackupsTotalSizeMb / 1024).toFixed(2)} GB
|
||||
{(backupConfig.maxBackupsTotalSizeMb / 1024).toFixed(2)} GB (~
|
||||
{backupConfig.maxBackupsTotalSizeMb / backupConfig.maxBackupSizeMb} backups)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -697,6 +810,7 @@ export const EditBackupConfigComponent = ({
|
||||
</div>
|
||||
|
||||
<EditStorageComponent
|
||||
user={user}
|
||||
workspaceId={database.workspaceId}
|
||||
isShowName
|
||||
isShowClose={false}
|
||||
|
||||
@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IS_CLOUD } from '../../../constants';
|
||||
import { type BackupConfig, BackupEncryption, backupConfigApi } from '../../../entity/backups';
|
||||
import { BackupNotificationType } from '../../../entity/backups/model/BackupNotificationType';
|
||||
import type { Database } from '../../../entity/databases';
|
||||
@@ -210,17 +211,21 @@ export const ShowBackupConfigComponent = ({ database }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Encryption</div>
|
||||
<div>{backupConfig.encryption === BackupEncryption.ENCRYPTED ? 'Enabled' : 'None'}</div>
|
||||
{!IS_CLOUD && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Encryption</div>
|
||||
<div>
|
||||
{backupConfig.encryption === BackupEncryption.ENCRYPTED ? 'Enabled' : 'None'}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Databasus or download them unencrypted via the 'Download' button."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Databasus or download them unencrypted via the 'Download' button."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Notifications</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type PostgresqlDatabase,
|
||||
databaseApi,
|
||||
} from '../../../entity/databases';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import { EditBackupConfigComponent } from '../../backups';
|
||||
import { CreateReadOnlyComponent } from './edit/CreateReadOnlyComponent';
|
||||
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
|
||||
@@ -18,8 +19,8 @@ import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComp
|
||||
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
|
||||
|
||||
interface Props {
|
||||
user: UserProfile;
|
||||
workspaceId: string;
|
||||
|
||||
onCreated: (databaseId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -62,7 +63,7 @@ const initializeDatabaseTypeData = (db: Database): Database => {
|
||||
}
|
||||
};
|
||||
|
||||
export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Props) => {
|
||||
export const CreateDatabaseComponent = ({ user, workspaceId, onCreated, onClose }: Props) => {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [backupConfig, setBackupConfig] = useState<BackupConfig | undefined>();
|
||||
const [database, setDatabase] = useState<Database>(createInitialDatabase(workspaceId));
|
||||
@@ -149,6 +150,7 @@ export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Pro
|
||||
if (step === 'backup-config') {
|
||||
return (
|
||||
<EditBackupConfigComponent
|
||||
user={user}
|
||||
database={database}
|
||||
isShowCancelButton={false}
|
||||
onCancel={() => onClose()}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRef, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { type Database, databaseApi } from '../../../entity/databases';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import { BackupsComponent } from '../../backups';
|
||||
import { HealthckeckAttemptsComponent } from '../../healthcheck';
|
||||
import { DatabaseConfigComponent } from './DatabaseConfigComponent';
|
||||
@@ -10,6 +11,7 @@ import { DatabaseConfigComponent } from './DatabaseConfigComponent';
|
||||
interface Props {
|
||||
contentHeight: number;
|
||||
databaseId: string;
|
||||
user: UserProfile;
|
||||
onDatabaseChanged: (database: Database) => void;
|
||||
onDatabaseDeleted: () => void;
|
||||
isCanManageDBs: boolean;
|
||||
@@ -18,6 +20,7 @@ interface Props {
|
||||
export const DatabaseComponent = ({
|
||||
contentHeight,
|
||||
databaseId,
|
||||
user,
|
||||
onDatabaseChanged,
|
||||
onDatabaseDeleted,
|
||||
isCanManageDBs,
|
||||
@@ -68,6 +71,7 @@ export const DatabaseComponent = ({
|
||||
{currentTab === 'config' && (
|
||||
<DatabaseConfigComponent
|
||||
database={database}
|
||||
user={user}
|
||||
setDatabase={setDatabase}
|
||||
onDatabaseChanged={onDatabaseChanged}
|
||||
onDatabaseDeleted={onDatabaseDeleted}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { backupConfigApi } from '../../../entity/backups';
|
||||
import { type Database, databaseApi } from '../../../entity/databases';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import { ToastHelper } from '../../../shared/toast';
|
||||
import { ConfirmationComponent } from '../../../shared/ui';
|
||||
import { EditBackupConfigComponent, ShowBackupConfigComponent } from '../../backups';
|
||||
@@ -22,6 +23,7 @@ import { ShowDatabaseSpecificDataComponent } from './show/ShowDatabaseSpecificDa
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
user: UserProfile;
|
||||
setDatabase: (database?: Database | undefined) => void;
|
||||
onDatabaseChanged: (database: Database) => void;
|
||||
onDatabaseDeleted: () => void;
|
||||
@@ -33,6 +35,7 @@ interface Props {
|
||||
|
||||
export const DatabaseConfigComponent = ({
|
||||
database,
|
||||
user,
|
||||
setDatabase,
|
||||
onDatabaseChanged,
|
||||
onDatabaseDeleted,
|
||||
@@ -311,6 +314,7 @@ export const DatabaseConfigComponent = ({
|
||||
{isEditBackupConfig ? (
|
||||
<EditBackupConfigComponent
|
||||
database={database}
|
||||
user={user}
|
||||
isShowCancelButton
|
||||
onCancel={() => {
|
||||
setIsEditBackupConfig(false);
|
||||
@@ -464,6 +468,7 @@ export const DatabaseConfigComponent = ({
|
||||
{isShowTransferDialog && (
|
||||
<DatabaseTransferDialogComponent
|
||||
database={database}
|
||||
user={user}
|
||||
currentStorageId={currentStorageId}
|
||||
onClose={() => setIsShowTransferDialog(false)}
|
||||
onTransferred={() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { type Database, databaseApi } from '../../../entity/databases';
|
||||
import type { Notifier } from '../../../entity/notifiers';
|
||||
import { notifierApi } from '../../../entity/notifiers';
|
||||
import { type Storage, getStorageLogoFromType, storageApi } from '../../../entity/storages';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import { type WorkspaceResponse, workspaceApi } from '../../../entity/workspaces';
|
||||
import { ToastHelper } from '../../../shared/toast';
|
||||
import { EditNotifierComponent } from '../../notifiers/ui/edit/EditNotifierComponent';
|
||||
@@ -15,6 +16,7 @@ import { EditStorageComponent } from '../../storages/ui/edit/EditStorageComponen
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
user: UserProfile;
|
||||
currentStorageId?: string;
|
||||
onClose: () => void;
|
||||
onTransferred: () => void;
|
||||
@@ -28,6 +30,7 @@ interface NotifierUsageInfo {
|
||||
|
||||
export const DatabaseTransferDialogComponent = ({
|
||||
database,
|
||||
user,
|
||||
currentStorageId,
|
||||
onClose,
|
||||
onTransferred,
|
||||
@@ -419,6 +422,7 @@ export const DatabaseTransferDialogComponent = ({
|
||||
|
||||
<EditStorageComponent
|
||||
workspaceId={selectedWorkspaceId}
|
||||
user={user}
|
||||
isShowName
|
||||
isShowClose={false}
|
||||
onClose={() => setIsShowCreateStorage(false)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { databaseApi } from '../../../entity/databases';
|
||||
import type { Database } from '../../../entity/databases';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import type { WorkspaceResponse } from '../../../entity/workspaces';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { CreateDatabaseComponent } from './CreateDatabaseComponent';
|
||||
@@ -12,12 +13,13 @@ import { DatabaseComponent } from './DatabaseComponent';
|
||||
interface Props {
|
||||
contentHeight: number;
|
||||
workspace: WorkspaceResponse;
|
||||
user: UserProfile;
|
||||
isCanManageDBs: boolean;
|
||||
}
|
||||
|
||||
const SELECTED_DATABASE_STORAGE_KEY = 'selectedDatabaseId';
|
||||
|
||||
export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }: Props) => {
|
||||
export const DatabasesComponent = ({ contentHeight, workspace, user, isCanManageDBs }: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [databases, setDatabases] = useState<Database[]>([]);
|
||||
@@ -157,6 +159,7 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
|
||||
<DatabaseComponent
|
||||
contentHeight={isMobile ? contentHeight - 50 : contentHeight}
|
||||
databaseId={selectedDatabaseId}
|
||||
user={user}
|
||||
onDatabaseChanged={() => {
|
||||
loadDatabases();
|
||||
}}
|
||||
@@ -185,6 +188,7 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
|
||||
<div className="mt-5" />
|
||||
|
||||
<CreateDatabaseComponent
|
||||
user={user}
|
||||
workspaceId={workspace.id}
|
||||
onCreated={(databaseId) => {
|
||||
loadDatabases(false, databaseId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button, Modal, Spin } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IS_CLOUD } from '../../../../constants';
|
||||
import { type Database, DatabaseType, databaseApi } from '../../../../entity/databases';
|
||||
|
||||
interface Props {
|
||||
@@ -193,9 +194,11 @@ export const CreateReadOnlyComponent = ({
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button className="mr-2 ml-auto" danger ghost onClick={handleSkip}>
|
||||
Skip
|
||||
</Button>
|
||||
{!IS_CLOUD && (
|
||||
<Button className="mr-2 ml-auto" danger ghost onClick={handleSkip}>
|
||||
Skip
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CopyOutlined, DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant
|
||||
import { App, Button, Checkbox, Input, InputNumber, Switch, Tooltip } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IS_CLOUD } from '../../../../constants';
|
||||
import { type Database, databaseApi } from '../../../../entity/databases';
|
||||
import { MariadbConnectionStringParser } from '../../../../entity/databases/model/mariadb/MariadbConnectionStringParser';
|
||||
import { ToastHelper } from '../../../../shared/toast';
|
||||
@@ -199,7 +200,7 @@ export const EditMariaDbSpecificDataComponent = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLocalhostDb && (
|
||||
{isLocalhostDb && !IS_CLOUD && (
|
||||
<div className="mb-1 flex">
|
||||
<div className="min-w-[150px]" />
|
||||
<div className="max-w-[200px] text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -401,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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CopyOutlined, DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant
|
||||
import { App, Button, Input, InputNumber, Switch, Tooltip } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IS_CLOUD } from '../../../../constants';
|
||||
import { type Database, databaseApi } from '../../../../entity/databases';
|
||||
import { MongodbConnectionStringParser } from '../../../../entity/databases/model/mongodb/MongodbConnectionStringParser';
|
||||
import { ToastHelper } from '../../../../shared/toast';
|
||||
@@ -201,7 +202,7 @@ export const EditMongoDbSpecificDataComponent = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLocalhostDb && (
|
||||
{isLocalhostDb && !IS_CLOUD && (
|
||||
<div className="mb-1 flex">
|
||||
<div className="min-w-[150px]" />
|
||||
<div className="max-w-[200px] text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -424,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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CopyOutlined } from '@ant-design/icons';
|
||||
import { App, Button, Input, InputNumber, Switch } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IS_CLOUD } from '../../../../constants';
|
||||
import { type Database, databaseApi } from '../../../../entity/databases';
|
||||
import { MySqlConnectionStringParser } from '../../../../entity/databases/model/mysql/MySqlConnectionStringParser';
|
||||
import { ToastHelper } from '../../../../shared/toast';
|
||||
@@ -196,7 +197,7 @@ export const EditMySqlSpecificDataComponent = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLocalhostDb && (
|
||||
{isLocalhostDb && !IS_CLOUD && (
|
||||
<div className="mb-1 flex">
|
||||
<div className="min-w-[150px]" />
|
||||
<div className="max-w-[200px] text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -352,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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CopyOutlined, DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant
|
||||
import { App, Button, Checkbox, Input, InputNumber, Select, Switch, Tooltip } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IS_CLOUD } from '../../../../constants';
|
||||
import { type Database, databaseApi } from '../../../../entity/databases';
|
||||
import { ConnectionStringParser } from '../../../../entity/databases/model/postgresql/ConnectionStringParser';
|
||||
import { ToastHelper } from '../../../../shared/toast';
|
||||
@@ -235,7 +236,7 @@ export const EditPostgreSqlSpecificDataComponent = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLocalhostDb && (
|
||||
{isLocalhostDb && !IS_CLOUD && (
|
||||
<div className="mb-1 flex">
|
||||
<div className="min-w-[150px]" />
|
||||
<div className="max-w-[200px] text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -372,7 +373,7 @@ export const EditPostgreSqlSpecificDataComponent = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isRestoreMode && (
|
||||
{isRestoreMode && !IS_CLOUD && (
|
||||
<div className="mb-5 flex w-full items-center">
|
||||
<div className="min-w-[150px]">CPU count</div>
|
||||
<div className="flex items-center">
|
||||
@@ -513,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.
|
||||
|
||||
1
frontend/src/features/playground/index.ts
Normal file
1
frontend/src/features/playground/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PlaygroundWarningComponent } from './ui/PlaygroundWarningComponent';
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Modal } from 'antd';
|
||||
import type { JSX } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IS_CLOUD } from '../../../constants';
|
||||
|
||||
const STORAGE_KEY = 'databasus_playground_info_dismissed';
|
||||
|
||||
const TIMEOUT_SECONDS = 30;
|
||||
|
||||
export const PlaygroundWarningComponent = (): JSX.Element => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(TIMEOUT_SECONDS);
|
||||
const [isButtonEnabled, setIsButtonEnabled] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
} catch (e) {
|
||||
console.warn('Failed to save playground modal state to localStorage:', e);
|
||||
}
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_CLOUD) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isDismissed = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
if (!isDismissed) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to read playground modal state from localStorage:', e);
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setRemainingSeconds((prev) => {
|
||||
if (prev <= 1) {
|
||||
setIsButtonEnabled(true);
|
||||
clearInterval(interval);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isVisible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Welcome to Databasus playground"
|
||||
open={isVisible}
|
||||
onOk={handleClose}
|
||||
okText={
|
||||
<div className="min-w-[100px]">
|
||||
{isButtonEnabled ? 'Understood' : `${remainingSeconds}`}
|
||||
</div>
|
||||
}
|
||||
okButtonProps={{ disabled: !isButtonEnabled }}
|
||||
closable={false}
|
||||
cancelButtonProps={{ style: { display: 'none' } }}
|
||||
width={500}
|
||||
centered
|
||||
maskClosable={false}
|
||||
>
|
||||
<div className="space-y-6 py-4">
|
||||
<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 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>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold">What is limit?</h3>
|
||||
<ul className="list-disc space-y-1 pl-5 text-gray-700 dark:text-gray-300">
|
||||
<li>Single backup size - 100 MB (~1.5 GB database)</li>
|
||||
<li>Store period - 7 days</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold">Is it secure?</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Yes, it's regular Databasus installation, secured and maintained by Databasus team.
|
||||
More about security{' '}
|
||||
<a
|
||||
href="https://databasus.com/security"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
you can read here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold">Can my data be currepted?</h3>
|
||||
<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. 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>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold">What if I see an issue?</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Create{' '}
|
||||
<a
|
||||
href="https://github.com/databasus/databasus/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
GitHub issue
|
||||
</a>{' '}
|
||||
or write{' '}
|
||||
<a
|
||||
href="https://t.me/databasus_community"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
to the community
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -40,6 +40,12 @@ export const StorageCardComponent = ({
|
||||
Has save error
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storage.isSystem && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user