Compare commits

...

10 Commits

Author SHA1 Message Date
Rostislav Dugin
c3ba4a7c5a Merge pull request #209 from databasus/develop
FIX (backups): Escape password over connection check to allow whitesp…
2026-01-04 20:50:08 +03:00
Rostislav Dugin
52c0f53608 FIX (backups): Escape password over connection check to allow whitespaces 2026-01-04 20:49:22 +03:00
github-actions[bot]
a5095acad4 Update CITATION.cff to v2.20.0 2026-01-04 14:54:48 +00:00
Rostislav Dugin
a6d32b5c09 Merge pull request #208 from databasus/develop
FIX (tests): Use unique DB names for PostgreSQL parallel tests
2026-01-04 17:26:52 +03:00
Rostislav Dugin
722560e824 FIX (tests): Use unique DB names for PostgreSQL parallel tests 2026-01-04 17:24:49 +03:00
Rostislav Dugin
496ac6120c Merge pull request #207 from databasus/develop
Develop
2026-01-04 16:24:22 +03:00
Rostislav Dugin
756c6c87af FIX (password): Trim db password at the moment of save and test connection instead right on the moment of input 2026-01-04 16:20:37 +03:00
Rostislav Dugin
a23d05b735 FIX (backups): Allow to make manual backups when scheduled are disabled 2026-01-04 16:11:14 +03:00
Rostislav Dugin
33a8d302eb FEATURE (workspaces): Add tranasfer between databases, storages and notifiers 2026-01-04 15:59:21 +03:00
github-actions[bot]
25ed1ffd2a Update CITATION.cff to v2.19.2 2026-01-02 13:30:15 +00:00
73 changed files with 4426 additions and 544 deletions

View File

@@ -32,5 +32,5 @@ keywords:
- mongodb
- mariadb
license: Apache-2.0
version: 2.19.1
date-released: "2026-01-02"
version: 2.20.0
date-released: "2026-01-04"

View File

@@ -217,6 +217,7 @@ func setUpDependencies() {
audit_logs.SetupDependencies()
notifiers.SetupDependencies()
storages.SetupDependencies()
backups_config.SetupDependencies()
}
func runBackgroundTasks(log *slog.Logger) {

View File

@@ -1,6 +1,7 @@
package audit_logs
import (
"errors"
"net/http"
user_models "databasus-backend/internal/features/users/models"
@@ -50,7 +51,7 @@ func (c *AuditLogController) GetGlobalAuditLogs(ctx *gin.Context) {
response, err := c.auditLogService.GetGlobalAuditLogs(user, request)
if err != nil {
if err.Error() == "only administrators can view global audit logs" {
if errors.Is(err, ErrOnlyAdminsCanViewGlobalLogs) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -99,7 +100,7 @@ func (c *AuditLogController) GetUserAuditLogs(ctx *gin.Context) {
response, err := c.auditLogService.GetUserAuditLogs(targetUserID, user, request)
if err != nil {
if err.Error() == "insufficient permissions to view user audit logs" {
if errors.Is(err, ErrInsufficientPermissionsToViewLogs) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}

View File

@@ -0,0 +1,12 @@
package audit_logs
import "errors"
var (
ErrOnlyAdminsCanViewGlobalLogs = errors.New(
"only administrators can view global audit logs",
)
ErrInsufficientPermissionsToViewLogs = errors.New(
"insufficient permissions to view user audit logs",
)
)

View File

@@ -1,7 +1,6 @@
package audit_logs
import (
"errors"
"log/slog"
"time"
@@ -44,7 +43,7 @@ func (s *AuditLogService) GetGlobalAuditLogs(
request *GetAuditLogsRequest,
) (*GetAuditLogsResponse, error) {
if user.Role != user_enums.UserRoleAdmin {
return nil, errors.New("only administrators can view global audit logs")
return nil, ErrOnlyAdminsCanViewGlobalLogs
}
limit := request.Limit
@@ -79,7 +78,7 @@ func (s *AuditLogService) GetUserAuditLogs(
) (*GetAuditLogsResponse, error) {
// Users can view their own logs, ADMIN can view any user's logs
if user.Role != user_enums.UserRoleAdmin && user.ID != targetUserID {
return nil, errors.New("insufficient permissions to view user audit logs")
return nil, ErrInsufficientPermissionsToViewLogs
}
limit := request.Limit

View File

@@ -25,6 +25,20 @@ func Test_MakeBackupForDbHavingBackupDayAgo_BackupCreated(t *testing.T) {
notifier := notifiers.CreateTestNotifier(workspace.ID)
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
defer func() {
// cleanup backups first
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)
}()
// Enable backups for the database
backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID)
assert.NoError(t, err)
@@ -54,24 +68,13 @@ func Test_MakeBackupForDbHavingBackupDayAgo_BackupCreated(t *testing.T) {
GetBackupBackgroundService().runPendingBackups()
time.Sleep(100 * time.Millisecond)
// Wait for backup to complete (runs in goroutine)
WaitForBackupCompletion(t, database.ID, 1, 10*time.Second)
// assertions
backups, err := backupRepository.FindByDatabaseID(database.ID)
assert.NoError(t, err)
assert.Len(t, backups, 2)
// cleanup
for _, backup := range backups {
err := backupRepository.DeleteByID(backup.ID)
assert.NoError(t, err)
}
databases.RemoveTestDatabase(database)
time.Sleep(50 * time.Millisecond) // Wait for cascading deletes
notifiers.RemoveTestNotifier(notifier)
storages.RemoveTestStorage(storage.ID)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func Test_MakeBackupForDbHavingHourAgoBackup_BackupSkipped(t *testing.T) {
@@ -83,6 +86,20 @@ func Test_MakeBackupForDbHavingHourAgoBackup_BackupSkipped(t *testing.T) {
notifier := notifiers.CreateTestNotifier(workspace.ID)
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
defer func() {
// cleanup backups first
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)
}()
// Enable backups for the database
backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID)
assert.NoError(t, err)
@@ -118,18 +135,6 @@ func Test_MakeBackupForDbHavingHourAgoBackup_BackupSkipped(t *testing.T) {
backups, err := backupRepository.FindByDatabaseID(database.ID)
assert.NoError(t, err)
assert.Len(t, backups, 1) // Should still be 1 backup, no new backup created
// cleanup
for _, backup := range backups {
err := backupRepository.DeleteByID(backup.ID)
assert.NoError(t, err)
}
databases.RemoveTestDatabase(database)
time.Sleep(50 * time.Millisecond) // Wait for cascading deletes
notifiers.RemoveTestNotifier(notifier)
storages.RemoveTestStorage(storage.ID)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func Test_MakeBackupHavingFailedBackupWithoutRetries_BackupSkipped(t *testing.T) {
@@ -141,6 +146,20 @@ func Test_MakeBackupHavingFailedBackupWithoutRetries_BackupSkipped(t *testing.T)
notifier := notifiers.CreateTestNotifier(workspace.ID)
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
defer func() {
// cleanup backups first
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)
}()
// Enable backups for the database with retries disabled
backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID)
assert.NoError(t, err)
@@ -180,18 +199,6 @@ func Test_MakeBackupHavingFailedBackupWithoutRetries_BackupSkipped(t *testing.T)
backups, err := backupRepository.FindByDatabaseID(database.ID)
assert.NoError(t, err)
assert.Len(t, backups, 1) // Should still be 1 backup, no retry attempted
// cleanup
for _, backup := range backups {
err := backupRepository.DeleteByID(backup.ID)
assert.NoError(t, err)
}
databases.RemoveTestDatabase(database)
time.Sleep(50 * time.Millisecond) // Wait for cascading deletes
notifiers.RemoveTestNotifier(notifier)
storages.RemoveTestStorage(storage.ID)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func Test_MakeBackupHavingFailedBackupWithRetries_BackupCreated(t *testing.T) {
@@ -203,6 +210,20 @@ func Test_MakeBackupHavingFailedBackupWithRetries_BackupCreated(t *testing.T) {
notifier := notifiers.CreateTestNotifier(workspace.ID)
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
defer func() {
// cleanup backups first
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)
}()
// Enable backups for the database with retries enabled
backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID)
assert.NoError(t, err)
@@ -236,24 +257,13 @@ func Test_MakeBackupHavingFailedBackupWithRetries_BackupCreated(t *testing.T) {
GetBackupBackgroundService().runPendingBackups()
time.Sleep(100 * time.Millisecond)
// Wait for backup to complete (runs in goroutine)
WaitForBackupCompletion(t, database.ID, 1, 10*time.Second)
// assertions
backups, err := backupRepository.FindByDatabaseID(database.ID)
assert.NoError(t, err)
assert.Len(t, backups, 2) // Should have 2 backups, retry was attempted
// cleanup
for _, backup := range backups {
err := backupRepository.DeleteByID(backup.ID)
assert.NoError(t, err)
}
databases.RemoveTestDatabase(database)
time.Sleep(100 * time.Millisecond) // Wait for cascading deletes
notifiers.RemoveTestNotifier(notifier)
storages.RemoveTestStorage(storage.ID)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func Test_MakeBackupHavingFailedBackupWithRetries_RetriesCountNotExceeded(t *testing.T) {
@@ -265,6 +275,20 @@ func Test_MakeBackupHavingFailedBackupWithRetries_RetriesCountNotExceeded(t *tes
notifier := notifiers.CreateTestNotifier(workspace.ID)
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
defer func() {
// cleanup backups first
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)
}()
// Enable backups for the database with retries enabled
backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID)
assert.NoError(t, err)
@@ -306,16 +330,60 @@ func Test_MakeBackupHavingFailedBackupWithRetries_RetriesCountNotExceeded(t *tes
backups, err := backupRepository.FindByDatabaseID(database.ID)
assert.NoError(t, err)
assert.Len(t, backups, 3) // Should have 3 backups, not more than max
// cleanup
for _, backup := range backups {
err := backupRepository.DeleteByID(backup.ID)
assert.NoError(t, err)
}
databases.RemoveTestDatabase(database)
time.Sleep(50 * time.Millisecond) // Wait for cascading deletes
notifiers.RemoveTestNotifier(notifier)
storages.RemoveTestStorage(storage.ID)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func Test_MakeBackgroundBackupWhenBakupsDisabled_BackupSkipped(t *testing.T) {
user := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
router := CreateTestRouter()
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", user, router)
storage := storages.CreateTestStorage(workspace.ID)
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)
}()
backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID)
assert.NoError(t, err)
timeOfDay := "04:00"
backupConfig.BackupInterval = &intervals.Interval{
Interval: intervals.IntervalDaily,
TimeOfDay: &timeOfDay,
}
backupConfig.IsBackupsEnabled = false
backupConfig.StorePeriod = period.PeriodWeek
backupConfig.Storage = storage
backupConfig.StorageID = &storage.ID
_, err = backups_config.GetBackupConfigService().SaveBackupConfig(backupConfig)
assert.NoError(t, err)
// add old backup that would trigger new backup if enabled
backupRepository.Save(&Backup{
DatabaseID: database.ID,
StorageID: storage.ID,
Status: BackupStatusCompleted,
CreatedAt: time.Now().UTC().Add(-24 * time.Hour),
})
GetBackupBackgroundService().runPendingBackups()
time.Sleep(100 * time.Millisecond)
backups, err := backupRepository.FindByDatabaseID(database.ID)
assert.NoError(t, err)
assert.Len(t, backups, 1)
}

View File

@@ -214,11 +214,6 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID, isLastTry bool) {
return
}
if !backupConfig.IsBackupsEnabled {
s.logger.Info("Backups are not enabled for this database")
return
}
if backupConfig.StorageID == nil {
s.logger.Error("Backup config storage ID is not defined")
return

View File

@@ -1,12 +1,16 @@
package backups
import (
"testing"
"time"
backups_config "databasus-backend/internal/features/backups/config"
"databasus-backend/internal/features/databases"
workspaces_controllers "databasus-backend/internal/features/workspaces/controllers"
workspaces_testing "databasus-backend/internal/features/workspaces/testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func CreateTestRouter() *gin.Engine {
@@ -18,3 +22,49 @@ func CreateTestRouter() *gin.Engine {
GetBackupController(),
)
}
// WaitForBackupCompletion waits for a new backup to be created and completed (or failed)
// for the given database. It checks for backups with count greater than expectedInitialCount.
func WaitForBackupCompletion(
t *testing.T,
databaseID uuid.UUID,
expectedInitialCount int,
timeout time.Duration,
) {
deadline := time.Now().UTC().Add(timeout)
for time.Now().UTC().Before(deadline) {
backups, err := backupRepository.FindByDatabaseID(databaseID)
if err != nil {
t.Logf("WaitForBackupCompletion: error finding backups: %v", err)
time.Sleep(50 * time.Millisecond)
continue
}
t.Logf(
"WaitForBackupCompletion: found %d backups (expected > %d)",
len(backups),
expectedInitialCount,
)
if len(backups) > expectedInitialCount {
// Check if the newest backup has completed or failed
newestBackup := backups[0]
t.Logf("WaitForBackupCompletion: newest backup status: %s", newestBackup.Status)
if newestBackup.Status == BackupStatusCompleted ||
newestBackup.Status == BackupStatusFailed ||
newestBackup.Status == BackupStatusCanceled {
t.Logf(
"WaitForBackupCompletion: backup finished with status %s",
newestBackup.Status,
)
return
}
}
time.Sleep(50 * time.Millisecond)
}
t.Logf("WaitForBackupCompletion: timeout waiting for backup to complete")
}

View File

@@ -64,10 +64,6 @@ func (uc *CreateMariadbBackupUsecase) Execute(
"storageId", storage.ID,
)
if !backupConfig.IsBackupsEnabled {
return nil, fmt.Errorf("backups are not enabled for this database: \"%s\"", db.Name)
}
mdb := db.Mariadb
if mdb == nil {
return nil, fmt.Errorf("mariadb database configuration is required")

View File

@@ -58,10 +58,6 @@ func (uc *CreateMongodbBackupUsecase) Execute(
"storageId", storage.ID,
)
if !backupConfig.IsBackupsEnabled {
return nil, fmt.Errorf("backups are not enabled for this database: \"%s\"", db.Name)
}
mdb := db.Mongodb
if mdb == nil {
return nil, fmt.Errorf("mongodb database configuration is required")

View File

@@ -64,10 +64,6 @@ func (uc *CreateMysqlBackupUsecase) Execute(
"storageId", storage.ID,
)
if !backupConfig.IsBackupsEnabled {
return nil, fmt.Errorf("backups are not enabled for this database: \"%s\"", db.Name)
}
my := db.Mysql
if my == nil {
return nil, fmt.Errorf("mysql database configuration is required")

View File

@@ -69,10 +69,6 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
storage.ID,
)
if !backupConfig.IsBackupsEnabled {
return nil, fmt.Errorf("backups are not enabled for this database: \"%s\"", db.Name)
}
pg := db.Postgresql
if pg == nil {

View File

@@ -1,9 +1,11 @@
package backups_config
import (
users_middleware "databasus-backend/internal/features/users/middleware"
"errors"
"net/http"
users_middleware "databasus-backend/internal/features/users/middleware"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -16,6 +18,8 @@ func (c *BackupConfigController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/backup-configs/save", c.SaveBackupConfig)
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)
router.POST("/backup-configs/database/:id/transfer", c.TransferDatabase)
}
// SaveBackupConfig
@@ -120,3 +124,86 @@ func (c *BackupConfigController) IsStorageUsing(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing})
}
// CountDatabasesForStorage
// @Summary Count databases using a storage
// @Description Get the count of databases that are using a specific storage
// @Tags backup-configs
// @Produce json
// @Param id path string true "Storage ID"
// @Success 200 {object} map[string]int
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /backup-configs/storage/{id}/databases-count [get]
func (c *BackupConfigController) CountDatabasesForStorage(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 storage ID"})
return
}
count, err := c.backupConfigService.CountDatabasesForStorage(user, id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"count": count})
}
// TransferDatabase
// @Summary Transfer database to another workspace
// @Description Transfer a database from one workspace to another. Can transfer to a new storage or transfer with the existing storage. Can also specify target notifiers from the target workspace.
// @Tags backup-configs
// @Accept json
// @Produce json
// @Param id path string true "Database ID"
// @Param request body TransferDatabaseRequest true "Transfer request with targetWorkspaceId, storage options (targetStorageId or isTransferWithStorage), and optional targetNotifierIds"
// @Success 200 {object} map[string]string "Database transferred successfully"
// @Failure 400 {object} map[string]string "Invalid request, target storage/notifier not in target workspace, or transfer failed"
// @Failure 401 {object} map[string]string "User not authenticated"
// @Failure 403 {object} map[string]string "Insufficient permissions"
// @Router /backup-configs/database/{id}/transfer [post]
func (c *BackupConfigController) TransferDatabase(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
}
var request TransferDatabaseRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if request.TargetWorkspaceID == uuid.Nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId is required"})
return
}
if err := c.backupConfigService.TransferDatabaseToWorkspace(user, id, &request); err != nil {
if errors.Is(err, ErrInsufficientPermissionsInSourceWorkspace) ||
errors.Is(err, ErrInsufficientPermissionsInTargetWorkspace) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "database transferred successfully"})
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ package backups_config
import (
"databasus-backend/internal/features/databases"
"databasus-backend/internal/features/notifiers"
"databasus-backend/internal/features/storages"
workspaces_services "databasus-backend/internal/features/workspaces/services"
)
@@ -11,6 +12,7 @@ var backupConfigService = &BackupConfigService{
backupConfigRepository,
databases.GetDatabaseService(),
storages.GetStorageService(),
notifiers.GetNotifierService(),
workspaces_services.GetWorkspaceService(),
nil,
}
@@ -25,3 +27,7 @@ func GetBackupConfigController() *BackupConfigController {
func GetBackupConfigService() *BackupConfigService {
return backupConfigService
}
func SetupDependencies() {
storages.GetStorageService().SetStorageDatabaseCounter(backupConfigService)
}

View File

@@ -0,0 +1,11 @@
package backups_config
import "github.com/google/uuid"
type TransferDatabaseRequest struct {
TargetWorkspaceID uuid.UUID `json:"targetWorkspaceId" binding:"required"`
TargetStorageID *uuid.UUID `json:"targetStorageId,omitempty"`
IsTransferWithStorage bool `json:"isTransferWithStorage,omitempty"`
IsTransferWithNotifiers bool `json:"isTransferWithNotifiers,omitempty"`
TargetNotifierIDs []uuid.UUID `json:"targetNotifierIds,omitempty"`
}

View File

@@ -0,0 +1,30 @@
package backups_config
import "errors"
var (
ErrInsufficientPermissionsInSourceWorkspace = errors.New(
"insufficient permissions to manage database in source workspace",
)
ErrInsufficientPermissionsInTargetWorkspace = errors.New(
"insufficient permissions to manage database in target workspace",
)
ErrTargetStorageNotInTargetWorkspace = errors.New(
"target storage does not belong to target workspace",
)
ErrTargetNotifierNotInTargetWorkspace = errors.New(
"target notifier does not belong to target workspace",
)
ErrStorageHasOtherAttachedDatabases = errors.New(
"storage has other attached databases and cannot be transferred with this database",
)
ErrDatabaseHasNoStorage = errors.New(
"database has no storage attached",
)
ErrDatabaseHasNoWorkspace = errors.New(
"database has no workspace",
)
ErrTargetStorageNotSpecified = errors.New(
"target storage is not specified",
)
)

View File

@@ -0,0 +1,166 @@
package backups_config
import (
"fmt"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"databasus-backend/internal/features/databases"
"databasus-backend/internal/features/notifiers"
"databasus-backend/internal/features/storages"
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"
test_utils "databasus-backend/internal/util/testing"
)
func Test_AttachNotifierFromSameWorkspace_SuccessfullyAttached(t *testing.T) {
router := createTestRouterWithNotifier()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
notifier := notifiers.CreateTestNotifier(workspace.ID)
database.Notifiers = []notifiers.Notifier{*notifier}
var response databases.Database
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/databases/update",
"Bearer "+owner.Token,
database,
http.StatusOK,
&response,
)
assert.Equal(t, database.ID, response.ID)
assert.Len(t, response.Notifiers, 1)
assert.Equal(t, notifier.ID, response.Notifiers[0].ID)
}
func Test_AttachNotifierFromDifferentWorkspace_ReturnsForbidden(t *testing.T) {
router := createTestRouterWithNotifier()
owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace1 := workspaces_testing.CreateTestWorkspace("Workspace 1", owner1, router)
database := createTestDatabaseViaAPI("Test Database", workspace1.ID, owner1.Token, router)
owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router)
notifier := notifiers.CreateTestNotifier(workspace2.ID)
database.Notifiers = []notifiers.Notifier{*notifier}
testResp := test_utils.MakePostRequest(
t,
router,
"/api/v1/databases/update",
"Bearer "+owner1.Token,
database,
http.StatusBadRequest,
)
assert.Contains(t, string(testResp.Body), "notifier does not belong to this workspace")
}
func Test_DeleteNotifierWithAttachedDatabases_CannotDelete(t *testing.T) {
router := createTestRouterWithNotifier()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
notifier := notifiers.CreateTestNotifier(workspace.ID)
database.Notifiers = []notifiers.Notifier{*notifier}
var response databases.Database
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/databases/update",
"Bearer "+owner.Token,
database,
http.StatusOK,
&response,
)
testResp := test_utils.MakeDeleteRequest(
t,
router,
fmt.Sprintf("/api/v1/notifiers/%s", notifier.ID.String()),
"Bearer "+owner.Token,
http.StatusBadRequest,
)
assert.Contains(
t,
string(testResp.Body),
"notifier has attached databases and cannot be deleted",
)
}
func Test_TransferNotifierWithAttachedDatabase_CannotTransfer(t *testing.T) {
router := createTestRouterWithNotifier()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
targetWorkspace := workspaces_testing.CreateTestWorkspace("Target Workspace", owner, router)
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
notifier := notifiers.CreateTestNotifier(workspace.ID)
database.Notifiers = []notifiers.Notifier{*notifier}
var response databases.Database
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/databases/update",
"Bearer "+owner.Token,
database,
http.StatusOK,
&response,
)
transferRequest := notifiers.TransferNotifierRequest{
TargetWorkspaceID: targetWorkspace.ID,
}
testResp := test_utils.MakePostRequest(
t,
router,
fmt.Sprintf("/api/v1/notifiers/%s/transfer", notifier.ID.String()),
"Bearer "+owner.Token,
transferRequest,
http.StatusBadRequest,
)
assert.Contains(
t,
string(testResp.Body),
"notifier has attached databases and cannot be transferred",
)
}
func createTestRouterWithNotifier() *gin.Engine {
router := workspaces_testing.CreateTestRouter(
workspaces_controllers.GetWorkspaceController(),
workspaces_controllers.GetMembershipController(),
databases.GetDatabaseController(),
GetBackupConfigController(),
storages.GetStorageController(),
notifiers.GetNotifierController(),
)
storages.SetupDependencies()
databases.SetupDependencies()
notifiers.SetupDependencies()
SetupDependencies()
return router
}

View File

@@ -102,3 +102,19 @@ func (r *BackupConfigRepository) IsStorageUsing(storageID uuid.UUID) (bool, erro
return count > 0, nil
}
func (r *BackupConfigRepository) GetDatabasesIDsByStorageID(
storageID uuid.UUID,
) ([]uuid.UUID, error) {
var databasesIDs []uuid.UUID
if err := storage.
GetDb().
Table("backup_configs").
Where("storage_id = ?", storageID).
Pluck("database_id", &databasesIDs).Error; err != nil {
return nil, err
}
return databasesIDs, nil
}

View File

@@ -5,6 +5,7 @@ import (
"databasus-backend/internal/features/databases"
"databasus-backend/internal/features/intervals"
"databasus-backend/internal/features/notifiers"
"databasus-backend/internal/features/storages"
users_models "databasus-backend/internal/features/users/models"
workspaces_services "databasus-backend/internal/features/workspaces/services"
@@ -17,6 +18,7 @@ type BackupConfigService struct {
backupConfigRepository *BackupConfigRepository
databaseService *databases.DatabaseService
storageService *storages.StorageService
notifierService *notifiers.NotifierService
workspaceService *workspaces_services.WorkspaceService
dbStorageChangeListener BackupConfigStorageChangeListener
@@ -28,6 +30,17 @@ func (s *BackupConfigService) SetDatabaseStorageChangeListener(
s.dbStorageChangeListener = dbStorageChangeListener
}
func (s *BackupConfigService) GetStorageAttachedDatabasesIDs(
storageID uuid.UUID,
) ([]uuid.UUID, error) {
databasesIDs, err := s.backupConfigRepository.GetDatabasesIDsByStorageID(storageID)
if err != nil {
return nil, err
}
return databasesIDs, nil
}
func (s *BackupConfigService) SaveBackupConfigWithAuth(
user *users_models.User,
backupConfig *BackupConfig,
@@ -53,6 +66,16 @@ func (s *BackupConfigService) SaveBackupConfigWithAuth(
return nil, errors.New("insufficient permissions to modify backup configuration")
}
if backupConfig.Storage != nil && backupConfig.Storage.ID != uuid.Nil {
storage, err := s.storageService.GetStorageByID(backupConfig.Storage.ID)
if err != nil {
return nil, err
}
if storage.WorkspaceID != *database.WorkspaceID {
return nil, errors.New("storage does not belong to the same workspace as the database")
}
}
return s.SaveBackupConfig(backupConfig)
}
@@ -129,6 +152,23 @@ func (s *BackupConfigService) IsStorageUsing(
return s.backupConfigRepository.IsStorageUsing(storageID)
}
func (s *BackupConfigService) CountDatabasesForStorage(
user *users_models.User,
storageID uuid.UUID,
) (int, error) {
_, err := s.storageService.GetStorage(user, storageID)
if err != nil {
return 0, err
}
databaseIDs, err := s.backupConfigRepository.GetDatabasesIDsByStorageID(storageID)
if err != nil {
return 0, err
}
return len(databaseIDs), nil
}
func (s *BackupConfigService) GetBackupConfigsWithEnabledBackups() ([]*BackupConfig, error) {
return s.backupConfigRepository.GetWithEnabledBackups()
}
@@ -176,6 +216,157 @@ func (s *BackupConfigService) initializeDefaultConfig(
return err
}
func (s *BackupConfigService) TransferDatabaseToWorkspace(
user *users_models.User,
databaseID uuid.UUID,
request *TransferDatabaseRequest,
) error {
database, err := s.databaseService.GetDatabaseByID(databaseID)
if err != nil {
return err
}
if database.WorkspaceID == nil {
return ErrDatabaseHasNoWorkspace
}
canManageSource, err := s.workspaceService.CanUserManageDBs(*database.WorkspaceID, user)
if err != nil {
return err
}
if !canManageSource {
return ErrInsufficientPermissionsInSourceWorkspace
}
canManageTarget, err := s.workspaceService.CanUserManageDBs(request.TargetWorkspaceID, user)
if err != nil {
return err
}
if !canManageTarget {
return ErrInsufficientPermissionsInTargetWorkspace
}
if err := s.validateTargetNotifiers(request); err != nil {
return err
}
backupConfig, err := s.GetBackupConfigByDbId(databaseID)
if err != nil {
return err
}
if request.IsTransferWithNotifiers {
s.transferNotifiers(user, database, request.TargetWorkspaceID)
}
if request.IsTransferWithStorage {
if backupConfig.StorageID == nil {
return ErrDatabaseHasNoStorage
}
attachedDatabasesIDs, err := s.GetStorageAttachedDatabasesIDs(*backupConfig.StorageID)
if err != nil {
return err
}
for _, dbID := range attachedDatabasesIDs {
if dbID != databaseID {
return ErrStorageHasOtherAttachedDatabases
}
}
err = s.storageService.TransferStorageToWorkspace(
user,
*backupConfig.StorageID,
request.TargetWorkspaceID,
&databaseID,
)
if err != nil {
return err
}
} else if request.TargetStorageID != nil {
targetStorage, err := s.storageService.GetStorageByID(*request.TargetStorageID)
if err != nil {
return err
}
if targetStorage.WorkspaceID != request.TargetWorkspaceID {
return ErrTargetStorageNotInTargetWorkspace
}
backupConfig.StorageID = request.TargetStorageID
backupConfig.Storage = targetStorage
_, err = s.backupConfigRepository.Save(backupConfig)
if err != nil {
return err
}
} else {
return ErrTargetStorageNotSpecified
}
err = s.databaseService.TransferDatabaseToWorkspace(databaseID, request.TargetWorkspaceID)
if err != nil {
return err
}
if len(request.TargetNotifierIDs) > 0 {
err = s.assignTargetNotifiers(databaseID, request.TargetNotifierIDs)
if err != nil {
return err
}
}
return nil
}
func (s *BackupConfigService) transferNotifiers(
user *users_models.User,
database *databases.Database,
targetWorkspaceID uuid.UUID,
) {
for _, notifier := range database.Notifiers {
_ = s.notifierService.TransferNotifierToWorkspace(
user,
notifier.ID,
targetWorkspaceID,
&database.ID,
)
}
}
func (s *BackupConfigService) validateTargetNotifiers(request *TransferDatabaseRequest) error {
for _, notifierID := range request.TargetNotifierIDs {
notifier, err := s.notifierService.GetNotifierByID(notifierID)
if err != nil {
return err
}
if notifier.WorkspaceID != request.TargetWorkspaceID {
return ErrTargetNotifierNotInTargetWorkspace
}
}
return nil
}
func (s *BackupConfigService) assignTargetNotifiers(
databaseID uuid.UUID,
notifierIDs []uuid.UUID,
) error {
targetNotifiers := make([]notifiers.Notifier, 0, len(notifierIDs))
for _, notifierID := range notifierIDs {
notifier, err := s.notifierService.GetNotifierByID(notifierID)
if err != nil {
return err
}
targetNotifiers = append(targetNotifiers, *notifier)
}
return s.databaseService.UpdateDatabaseNotifiers(databaseID, targetNotifiers)
}
func storageIDsEqual(id1, id2 *uuid.UUID) bool {
if id1 == nil && id2 == nil {
return true

View File

@@ -0,0 +1,229 @@
package backups_config
import (
"fmt"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"databasus-backend/internal/features/databases"
"databasus-backend/internal/features/intervals"
"databasus-backend/internal/features/storages"
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/util/period"
test_utils "databasus-backend/internal/util/testing"
)
func Test_AttachStorageFromSameWorkspace_SuccessfullyAttached(t *testing.T) {
router := createTestRouterWithStorage()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
storage := createTestStorage(workspace.ID)
timeOfDay := "04:00"
request := BackupConfig{
DatabaseID: database.ID,
IsBackupsEnabled: true,
StorePeriod: period.PeriodWeek,
BackupInterval: &intervals.Interval{
Interval: intervals.IntervalDaily,
TimeOfDay: &timeOfDay,
},
Storage: storage,
SendNotificationsOn: []BackupNotificationType{
NotificationBackupFailed,
},
IsRetryIfFailed: true,
MaxFailedTriesCount: 3,
Encryption: BackupEncryptionNone,
}
var response BackupConfig
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/backup-configs/save",
"Bearer "+owner.Token,
request,
http.StatusOK,
&response,
)
assert.Equal(t, database.ID, response.DatabaseID)
assert.NotNil(t, response.StorageID)
assert.Equal(t, storage.ID, *response.StorageID)
}
func Test_AttachStorageFromDifferentWorkspace_ReturnsForbidden(t *testing.T) {
router := createTestRouterWithStorage()
owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace1 := workspaces_testing.CreateTestWorkspace("Workspace 1", owner1, router)
database := createTestDatabaseViaAPI("Test Database", workspace1.ID, owner1.Token, router)
owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router)
storage := createTestStorage(workspace2.ID)
timeOfDay := "04:00"
request := BackupConfig{
DatabaseID: database.ID,
IsBackupsEnabled: true,
StorePeriod: period.PeriodWeek,
BackupInterval: &intervals.Interval{
Interval: intervals.IntervalDaily,
TimeOfDay: &timeOfDay,
},
Storage: storage,
SendNotificationsOn: []BackupNotificationType{
NotificationBackupFailed,
},
IsRetryIfFailed: true,
MaxFailedTriesCount: 3,
Encryption: BackupEncryptionNone,
}
testResp := test_utils.MakePostRequest(
t,
router,
"/api/v1/backup-configs/save",
"Bearer "+owner1.Token,
request,
http.StatusBadRequest,
)
assert.Contains(t, string(testResp.Body), "storage does not belong to the same workspace")
}
func Test_DeleteStorageWithAttachedDatabases_CannotDelete(t *testing.T) {
router := createTestRouterWithStorage()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
storage := createTestStorage(workspace.ID)
timeOfDay := "04:00"
request := BackupConfig{
DatabaseID: database.ID,
IsBackupsEnabled: true,
StorePeriod: period.PeriodWeek,
BackupInterval: &intervals.Interval{
Interval: intervals.IntervalDaily,
TimeOfDay: &timeOfDay,
},
Storage: storage,
SendNotificationsOn: []BackupNotificationType{
NotificationBackupFailed,
},
IsRetryIfFailed: true,
MaxFailedTriesCount: 3,
Encryption: BackupEncryptionNone,
}
var response BackupConfig
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/backup-configs/save",
"Bearer "+owner.Token,
request,
http.StatusOK,
&response,
)
testResp := test_utils.MakeDeleteRequest(
t,
router,
fmt.Sprintf("/api/v1/storages/%s", storage.ID.String()),
"Bearer "+owner.Token,
http.StatusBadRequest,
)
assert.Contains(
t,
string(testResp.Body),
"storage has attached databases and cannot be deleted",
)
}
func Test_TransferStorageWithAttachedDatabase_CannotTransfer(t *testing.T) {
router := createTestRouterWithStorage()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
targetWorkspace := workspaces_testing.CreateTestWorkspace("Target Workspace", owner, router)
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
storage := createTestStorage(workspace.ID)
timeOfDay := "04:00"
request := BackupConfig{
DatabaseID: database.ID,
IsBackupsEnabled: true,
StorePeriod: period.PeriodWeek,
BackupInterval: &intervals.Interval{
Interval: intervals.IntervalDaily,
TimeOfDay: &timeOfDay,
},
Storage: storage,
SendNotificationsOn: []BackupNotificationType{
NotificationBackupFailed,
},
IsRetryIfFailed: true,
MaxFailedTriesCount: 3,
Encryption: BackupEncryptionNone,
}
var response BackupConfig
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/backup-configs/save",
"Bearer "+owner.Token,
request,
http.StatusOK,
&response,
)
transferRequest := storages.TransferStorageRequest{
TargetWorkspaceID: targetWorkspace.ID,
}
testResp := test_utils.MakePostRequest(
t,
router,
fmt.Sprintf("/api/v1/storages/%s/transfer", storage.ID.String()),
"Bearer "+owner.Token,
transferRequest,
http.StatusBadRequest,
)
assert.Contains(
t,
string(testResp.Body),
"storage has attached databases and cannot be transferred",
)
}
func createTestRouterWithStorage() *gin.Engine {
router := workspaces_testing.CreateTestRouter(
workspaces_controllers.GetWorkspaceController(),
workspaces_controllers.GetMembershipController(),
databases.GetDatabaseController(),
GetBackupConfigController(),
storages.GetStorageController(),
)
storages.SetupDependencies()
databases.SetupDependencies()
SetupDependencies()
return router
}

View File

@@ -26,6 +26,7 @@ func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect)
router.POST("/databases/:id/copy", c.CopyDatabase)
router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing)
router.GET("/databases/notifier/:id/databases-count", c.CountDatabasesByNotifier)
router.POST("/databases/is-readonly", c.IsUserReadOnly)
router.POST("/databases/create-readonly-user", c.CreateReadOnlyUser)
}
@@ -299,6 +300,39 @@ func (c *DatabaseController) IsNotifierUsing(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing})
}
// CountDatabasesByNotifier
// @Summary Count databases using a notifier
// @Description Get the count of databases that are using a specific notifier
// @Tags databases
// @Produce json
// @Param id path string true "Notifier ID"
// @Success 200 {object} map[string]int
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /databases/notifier/{id}/databases-count [get]
func (c *DatabaseController) CountDatabasesByNotifier(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 notifier ID"})
return
}
count, err := c.databaseService.CountDatabasesByNotifier(user, id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"count": count})
}
// CopyDatabase
// @Summary Copy a database
// @Description Copy an existing database configuration

View File

@@ -695,16 +695,22 @@ func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string, password s
}
return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s default_query_exec_mode=simple_protocol standard_conforming_strings=on client_encoding=UTF8",
"host=%s port=%d user=%s password='%s' dbname=%s sslmode=%s default_query_exec_mode=simple_protocol standard_conforming_strings=on client_encoding=UTF8",
p.Host,
p.Port,
p.Username,
password,
escapeConnectionStringValue(password),
dbName,
sslMode,
)
}
func escapeConnectionStringValue(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `'`, `\'`)
return value
}
func decryptPasswordIfNeeded(
password string,
encryptor encryption.FieldEncryptor,

View File

@@ -0,0 +1,94 @@
package postgresql
import (
"fmt"
"log/slog"
"os"
"strconv"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
"databasus-backend/internal/config"
"databasus-backend/internal/util/tools"
)
func Test_TestConnection_PasswordContainingSpaces_TestedSuccessfully(t *testing.T) {
env := config.GetEnv()
container := connectToTestPostgresContainer(t, env.TestPostgres16Port)
defer container.DB.Close()
passwordWithSpaces := "test password with spaces"
usernameWithSpaces := fmt.Sprintf("testuser_spaces_%s", uuid.New().String()[:8])
_, err := container.DB.Exec(fmt.Sprintf(
`CREATE USER "%s" WITH PASSWORD '%s' LOGIN`,
usernameWithSpaces,
passwordWithSpaces,
))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf(
`GRANT CONNECT ON DATABASE "%s" TO "%s"`,
container.Database,
usernameWithSpaces,
))
assert.NoError(t, err)
defer func() {
_, _ = container.DB.Exec(fmt.Sprintf(`DROP USER IF EXISTS "%s"`, usernameWithSpaces))
}()
pgModel := &PostgresqlDatabase{
Version: tools.GetPostgresqlVersionEnum("16"),
Host: container.Host,
Port: container.Port,
Username: usernameWithSpaces,
Password: passwordWithSpaces,
Database: &container.Database,
IsHttps: false,
CpuCount: 1,
}
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
err = pgModel.TestConnection(logger, nil, uuid.New())
assert.NoError(t, err)
}
type testPostgresContainer struct {
Host string
Port int
Username string
Password string
Database string
DB *sqlx.DB
}
func connectToTestPostgresContainer(t *testing.T, port string) *testPostgresContainer {
dbName := "testdb"
password := "testpassword"
username := "testuser"
host := "localhost"
portInt, err := strconv.Atoi(port)
assert.NoError(t, err)
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, portInt, username, password, dbName)
db, err := sqlx.Connect("postgres", dsn)
assert.NoError(t, err)
return &testPostgresContainer{
Host: host,
Port: portInt,
Username: username,
Password: password,
Database: dbName,
DB: db,
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"databasus-backend/internal/config"
"databasus-backend/internal/util/tools"
@@ -343,7 +344,7 @@ func Test_CreateReadOnlyUser_Supabase_UserCanReadButNotWrite(t *testing.T) {
)
adminDB, err := sqlx.Connect("postgres", dsn)
assert.NoError(t, err)
require.NoError(t, err)
defer adminDB.Close()
tableName := fmt.Sprintf(

View File

@@ -39,4 +39,5 @@ func GetDatabaseController() *DatabaseController {
func SetupDependencies() {
workspaces_services.GetWorkspaceService().AddWorkspaceDeletionListener(databaseService)
notifiers.GetNotifierService().SetNotifierDatabaseCounter(databaseService)
}

View File

@@ -243,3 +243,19 @@ func (r *DatabaseRepository) GetAllDatabases() ([]*Database, error) {
return databases, nil
}
func (r *DatabaseRepository) GetDatabasesIDsByNotifierID(
notifierID uuid.UUID,
) ([]uuid.UUID, error) {
var databasesIDs []uuid.UUID
if err := storage.
GetDb().
Table("database_notifiers").
Where("notifier_id = ?", notifierID).
Pluck("database_id", &databasesIDs).Error; err != nil {
return nil, err
}
return databasesIDs, nil
}

View File

@@ -52,6 +52,17 @@ func (s *DatabaseService) AddDbCopyListener(
s.dbCopyListener = append(s.dbCopyListener, dbCopyListener)
}
func (s *DatabaseService) GetNotifierAttachedDatabasesIDs(
notifierID uuid.UUID,
) ([]uuid.UUID, error) {
databasesIDs, err := s.dbRepository.GetDatabasesIDsByNotifierID(notifierID)
if err != nil {
return nil, err
}
return databasesIDs, nil
}
func (s *DatabaseService) CreateDatabase(
user *users_models.User,
workspaceID uuid.UUID,
@@ -126,6 +137,12 @@ func (s *DatabaseService) UpdateDatabase(
return err
}
for _, notifier := range database.Notifiers {
if notifier.WorkspaceID != *existingDatabase.WorkspaceID {
return errors.New("notifier does not belong to this workspace")
}
}
existingDatabase.Update(database)
if err := existingDatabase.Validate(); err != nil {
@@ -251,6 +268,23 @@ func (s *DatabaseService) IsNotifierUsing(
return s.dbRepository.IsNotifierUsing(notifierID)
}
func (s *DatabaseService) CountDatabasesByNotifier(
user *users_models.User,
notifierID uuid.UUID,
) (int, error) {
_, err := s.notifierService.GetNotifier(user, notifierID)
if err != nil {
return 0, err
}
databaseIDs, err := s.dbRepository.GetDatabasesIDsByNotifierID(notifierID)
if err != nil {
return 0, err
}
return len(databaseIDs), nil
}
func (s *DatabaseService) TestDatabaseConnection(
user *users_models.User,
databaseID uuid.UUID,
@@ -481,6 +515,48 @@ func (s *DatabaseService) CopyDatabase(
return copiedDatabase, nil
}
func (s *DatabaseService) TransferDatabaseToWorkspace(
databaseID uuid.UUID,
targetWorkspaceID uuid.UUID,
) error {
database, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return err
}
sourceWorkspaceID := database.WorkspaceID
database.WorkspaceID = &targetWorkspaceID
_, err = s.dbRepository.Save(database)
if err != nil {
return err
}
s.auditLogService.WriteAuditLog(
fmt.Sprintf("Database transferred: %s from workspace %s to workspace %s",
database.Name, sourceWorkspaceID, targetWorkspaceID),
nil,
&targetWorkspaceID,
)
return nil
}
func (s *DatabaseService) UpdateDatabaseNotifiers(
databaseID uuid.UUID,
newNotifiers []notifiers.Notifier,
) error {
database, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return err
}
database.Notifiers = newNotifiers
_, err = s.dbRepository.Save(database)
return err
}
func (s *DatabaseService) SetHealthStatus(
databaseID uuid.UUID,
healthStatus *HealthStatus,

View File

@@ -1,6 +1,8 @@
package notifiers
import (
"errors"
users_middleware "databasus-backend/internal/features/users/middleware"
workspaces_services "databasus-backend/internal/features/workspaces/services"
"net/http"
@@ -20,6 +22,7 @@ func (c *NotifierController) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/notifiers/:id", c.GetNotifier)
router.DELETE("/notifiers/:id", c.DeleteNotifier)
router.POST("/notifiers/:id/test", c.SendTestNotification)
router.POST("/notifiers/:id/transfer", c.TransferNotifierToWorkspace)
router.POST("/notifiers/direct-test", c.SendTestNotificationDirect)
}
@@ -55,7 +58,7 @@ func (c *NotifierController) SaveNotifier(ctx *gin.Context) {
}
if err := c.notifierService.SaveNotifier(user, request.WorkspaceID, &request); err != nil {
if err.Error() == "insufficient permissions to manage notifier in this workspace" {
if errors.Is(err, ErrInsufficientPermissionsToManageNotifier) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -93,7 +96,7 @@ func (c *NotifierController) GetNotifier(ctx *gin.Context) {
notifier, err := c.notifierService.GetNotifier(user, id)
if err != nil {
if err.Error() == "insufficient permissions to view notifier in this workspace" {
if errors.Is(err, ErrInsufficientPermissionsToViewNotifier) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -137,7 +140,7 @@ func (c *NotifierController) GetNotifiers(ctx *gin.Context) {
notifiers, err := c.notifierService.GetNotifiers(user, workspaceID)
if err != nil {
if err.Error() == "insufficient permissions to view notifiers in this workspace" {
if errors.Is(err, ErrInsufficientPermissionsToViewNotifiers) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -174,7 +177,7 @@ func (c *NotifierController) DeleteNotifier(ctx *gin.Context) {
}
if err := c.notifierService.DeleteNotifier(user, id); err != nil {
if err.Error() == "insufficient permissions to manage notifier in this workspace" {
if errors.Is(err, ErrInsufficientPermissionsToManageNotifier) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -211,7 +214,7 @@ func (c *NotifierController) SendTestNotification(ctx *gin.Context) {
}
if err := c.notifierService.SendTestNotification(user, id); err != nil {
if err.Error() == "insufficient permissions to test notifier in this workspace" {
if errors.Is(err, ErrInsufficientPermissionsToTestNotifier) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -222,6 +225,57 @@ func (c *NotifierController) SendTestNotification(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"message": "test notification sent successfully"})
}
// TransferNotifierToWorkspace
// @Summary Transfer notifier to another workspace
// @Description Transfer a notifier from one workspace to another
// @Tags notifiers
// @Accept json
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param id path string true "Notifier ID"
// @Param request body TransferNotifierRequest true "Target workspace ID"
// @Success 200
// @Failure 400
// @Failure 401
// @Failure 403
// @Router /notifiers/{id}/transfer [post]
func (c *NotifierController) TransferNotifierToWorkspace(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 notifier ID"})
return
}
var request TransferNotifierRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if request.TargetWorkspaceID == uuid.Nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId is required"})
return
}
if err := c.notifierService.TransferNotifierToWorkspace(user, id, request.TargetWorkspaceID, nil); err != nil {
if errors.Is(err, ErrInsufficientPermissionsInSourceWorkspace) ||
errors.Is(err, ErrInsufficientPermissionsInTargetWorkspace) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "notifier transferred successfully"})
}
// SendTestNotificationDirect
// @Summary Send test notification directly
// @Description Send a test notification using a notifier object provided in the request

View File

@@ -202,164 +202,161 @@ func Test_SendTestNotificationExisting_NotificationSent(t *testing.T) {
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func Test_ViewerCanViewNotifiers_ButCannotModify(t *testing.T) {
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
viewer := users_testing.CreateTestUser(users_enums.UserRoleMember)
router := createRouter()
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
workspaces_testing.AddMemberToWorkspace(
workspace,
viewer,
users_enums.WorkspaceRoleViewer,
owner.Token,
router,
)
func Test_WorkspaceRolePermissions_Notifiers(t *testing.T) {
tests := []struct {
name string
workspaceRole *users_enums.WorkspaceRole
isGlobalAdmin bool
canCreate bool
canUpdate bool
canDelete bool
}{
{
name: "owner can manage notifiers",
workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(),
isGlobalAdmin: false,
canCreate: true,
canUpdate: true,
canDelete: true,
},
{
name: "admin can manage notifiers",
workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(),
isGlobalAdmin: false,
canCreate: true,
canUpdate: true,
canDelete: true,
},
{
name: "member can manage notifiers",
workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(),
isGlobalAdmin: false,
canCreate: true,
canUpdate: true,
canDelete: true,
},
{
name: "viewer can view but cannot modify notifiers",
workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(),
isGlobalAdmin: false,
canCreate: false,
canUpdate: false,
canDelete: false,
},
{
name: "global admin can manage notifiers",
workspaceRole: nil,
isGlobalAdmin: true,
canCreate: true,
canUpdate: true,
canDelete: true,
},
}
notifier := createNewNotifier(workspace.ID)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := createRouter()
GetNotifierService().SetNotifierDatabaseCounter(&mockNotifierDatabaseCounter{})
var savedNotifier Notifier
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/notifiers",
"Bearer "+owner.Token,
*notifier,
http.StatusOK,
&savedNotifier,
)
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
// Viewer can GET notifiers
var notifiers []Notifier
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/notifiers?workspace_id=%s", workspace.ID.String()),
"Bearer "+viewer.Token,
http.StatusOK,
&notifiers,
)
assert.Len(t, notifiers, 1)
var testUserToken string
if tt.isGlobalAdmin {
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
testUserToken = admin.Token
} else if tt.workspaceRole != nil && *tt.workspaceRole == users_enums.WorkspaceRoleOwner {
testUserToken = owner.Token
} else if tt.workspaceRole != nil {
testUser := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspaces_testing.AddMemberToWorkspace(
workspace,
testUser,
*tt.workspaceRole,
owner.Token,
router,
)
testUserToken = testUser.Token
}
// Viewer cannot CREATE notifier
newNotifier := createNewNotifier(workspace.ID)
test_utils.MakePostRequest(
t, router, "/api/v1/notifiers", "Bearer "+viewer.Token, *newNotifier, http.StatusForbidden,
)
// Owner creates initial notifier for all test cases
var ownerNotifier Notifier
notifier := createNewNotifier(workspace.ID)
test_utils.MakePostRequestAndUnmarshal(
t, router, "/api/v1/notifiers", "Bearer "+owner.Token,
*notifier, http.StatusOK, &ownerNotifier,
)
// Viewer cannot UPDATE notifier
savedNotifier.Name = "Updated by viewer"
test_utils.MakePostRequest(
t, router, "/api/v1/notifiers", "Bearer "+viewer.Token, savedNotifier, http.StatusForbidden,
)
// Test GET notifiers
var notifiers []Notifier
test_utils.MakeGetRequestAndUnmarshal(
t, router,
fmt.Sprintf("/api/v1/notifiers?workspace_id=%s", workspace.ID.String()),
"Bearer "+testUserToken, http.StatusOK, &notifiers,
)
assert.Len(t, notifiers, 1)
// Viewer cannot DELETE notifier
test_utils.MakeDeleteRequest(
t,
router,
fmt.Sprintf("/api/v1/notifiers/%s", savedNotifier.ID.String()),
"Bearer "+viewer.Token,
http.StatusForbidden,
)
// Test CREATE notifier
createStatusCode := http.StatusOK
if !tt.canCreate {
createStatusCode = http.StatusForbidden
}
newNotifier := createNewNotifier(workspace.ID)
var savedNotifier Notifier
if tt.canCreate {
test_utils.MakePostRequestAndUnmarshal(
t, router, "/api/v1/notifiers", "Bearer "+testUserToken,
*newNotifier, createStatusCode, &savedNotifier,
)
assert.NotEmpty(t, savedNotifier.ID)
} else {
test_utils.MakePostRequest(
t, router, "/api/v1/notifiers", "Bearer "+testUserToken,
*newNotifier, createStatusCode,
)
}
deleteNotifier(t, router, savedNotifier.ID, workspace.ID, owner.Token)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
// Test UPDATE notifier
updateStatusCode := http.StatusOK
if !tt.canUpdate {
updateStatusCode = http.StatusForbidden
}
ownerNotifier.Name = "Updated by test user"
if tt.canUpdate {
var updatedNotifier Notifier
test_utils.MakePostRequestAndUnmarshal(
t, router, "/api/v1/notifiers", "Bearer "+testUserToken,
ownerNotifier, updateStatusCode, &updatedNotifier,
)
assert.Equal(t, "Updated by test user", updatedNotifier.Name)
} else {
test_utils.MakePostRequest(
t, router, "/api/v1/notifiers", "Bearer "+testUserToken,
ownerNotifier, updateStatusCode,
)
}
func Test_MemberCanManageNotifiers(t *testing.T) {
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
member := users_testing.CreateTestUser(users_enums.UserRoleMember)
router := createRouter()
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
workspaces_testing.AddMemberToWorkspace(
workspace,
member,
users_enums.WorkspaceRoleMember,
owner.Token,
router,
)
// Test DELETE notifier
deleteStatusCode := http.StatusOK
if !tt.canDelete {
deleteStatusCode = http.StatusForbidden
}
test_utils.MakeDeleteRequest(
t, router,
fmt.Sprintf("/api/v1/notifiers/%s", ownerNotifier.ID.String()),
"Bearer "+testUserToken, deleteStatusCode,
)
notifier := createNewNotifier(workspace.ID)
// Member can CREATE notifier
var savedNotifier Notifier
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/notifiers",
"Bearer "+member.Token,
*notifier,
http.StatusOK,
&savedNotifier,
)
assert.NotEmpty(t, savedNotifier.ID)
// Member can UPDATE notifier
savedNotifier.Name = "Updated by member"
var updatedNotifier Notifier
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/notifiers",
"Bearer "+member.Token,
savedNotifier,
http.StatusOK,
&updatedNotifier,
)
assert.Equal(t, "Updated by member", updatedNotifier.Name)
// Member can DELETE notifier
test_utils.MakeDeleteRequest(
t,
router,
fmt.Sprintf("/api/v1/notifiers/%s", savedNotifier.ID.String()),
"Bearer "+member.Token,
http.StatusOK,
)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func Test_AdminCanManageNotifiers(t *testing.T) {
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
admin := users_testing.CreateTestUser(users_enums.UserRoleMember)
router := createRouter()
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
workspaces_testing.AddMemberToWorkspace(
workspace,
admin,
users_enums.WorkspaceRoleAdmin,
owner.Token,
router,
)
notifier := createNewNotifier(workspace.ID)
// Admin can CREATE, UPDATE, DELETE
var savedNotifier Notifier
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/notifiers",
"Bearer "+admin.Token,
*notifier,
http.StatusOK,
&savedNotifier,
)
savedNotifier.Name = "Updated by admin"
test_utils.MakePostRequest(
t, router, "/api/v1/notifiers", "Bearer "+admin.Token, savedNotifier, http.StatusOK,
)
test_utils.MakeDeleteRequest(
t,
router,
fmt.Sprintf("/api/v1/notifiers/%s", savedNotifier.ID.String()),
"Bearer "+admin.Token,
http.StatusOK,
)
workspaces_testing.RemoveTestWorkspace(workspace, router)
// Cleanup
if tt.canCreate {
deleteNotifier(t, router, savedNotifier.ID, workspace.ID, owner.Token)
}
if !tt.canDelete {
deleteNotifier(t, router, ownerNotifier.ID, workspace.ID, owner.Token)
}
workspaces_testing.RemoveTestWorkspace(workspace, router)
})
}
}
func Test_UserNotInWorkspace_CannotAccessNotifiers(t *testing.T) {
@@ -965,6 +962,192 @@ func Test_CreateNotifier_AllSensitiveFieldsEncryptedInDB(t *testing.T) {
}
}
func Test_TransferNotifier_PermissionsEnforced(t *testing.T) {
tests := []struct {
name string
sourceRole *users_enums.WorkspaceRole
targetRole *users_enums.WorkspaceRole
isGlobalAdmin bool
expectSuccess bool
expectedStatusCode int
}{
{
name: "owner in both workspaces can transfer",
sourceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(),
targetRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(),
isGlobalAdmin: false,
expectSuccess: true,
expectedStatusCode: http.StatusOK,
},
{
name: "admin in both workspaces can transfer",
sourceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(),
targetRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(),
isGlobalAdmin: false,
expectSuccess: true,
expectedStatusCode: http.StatusOK,
},
{
name: "member in both workspaces can transfer",
sourceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(),
targetRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(),
isGlobalAdmin: false,
expectSuccess: true,
expectedStatusCode: http.StatusOK,
},
{
name: "viewer in both workspaces cannot transfer",
sourceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(),
targetRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(),
isGlobalAdmin: false,
expectSuccess: false,
expectedStatusCode: http.StatusForbidden,
},
{
name: "global admin can transfer",
sourceRole: nil,
targetRole: nil,
isGlobalAdmin: true,
expectSuccess: true,
expectedStatusCode: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := createRouter()
GetNotifierService().SetNotifierDatabaseCounter(&mockNotifierDatabaseCounter{})
sourceOwner := users_testing.CreateTestUser(users_enums.UserRoleMember)
targetOwner := users_testing.CreateTestUser(users_enums.UserRoleMember)
sourceWorkspace := workspaces_testing.CreateTestWorkspace(
"Source Workspace",
sourceOwner,
router,
)
targetWorkspace := workspaces_testing.CreateTestWorkspace(
"Target Workspace",
targetOwner,
router,
)
notifier := createNewNotifier(sourceWorkspace.ID)
var savedNotifier Notifier
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/notifiers",
"Bearer "+sourceOwner.Token,
*notifier,
http.StatusOK,
&savedNotifier,
)
var testUserToken string
if tt.isGlobalAdmin {
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
testUserToken = admin.Token
} else if tt.sourceRole != nil {
testUser := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspaces_testing.AddMemberToWorkspace(sourceWorkspace, testUser, *tt.sourceRole, sourceOwner.Token, router)
workspaces_testing.AddMemberToWorkspace(targetWorkspace, testUser, *tt.targetRole, targetOwner.Token, router)
testUserToken = testUser.Token
}
request := TransferNotifierRequest{
TargetWorkspaceID: targetWorkspace.ID,
}
testResp := test_utils.MakePostRequest(
t,
router,
fmt.Sprintf("/api/v1/notifiers/%s/transfer", savedNotifier.ID.String()),
"Bearer "+testUserToken,
request,
tt.expectedStatusCode,
)
if tt.expectSuccess {
assert.Contains(t, string(testResp.Body), "transferred successfully")
var retrievedNotifier Notifier
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/notifiers/%s", savedNotifier.ID.String()),
"Bearer "+targetOwner.Token,
http.StatusOK,
&retrievedNotifier,
)
assert.Equal(t, targetWorkspace.ID, retrievedNotifier.WorkspaceID)
deleteNotifier(t, router, savedNotifier.ID, targetWorkspace.ID, targetOwner.Token)
} else {
assert.Contains(t, string(testResp.Body), "insufficient permissions")
deleteNotifier(t, router, savedNotifier.ID, sourceWorkspace.ID, sourceOwner.Token)
}
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
})
}
}
func Test_TransferNotifierNotManagableWorkspace_TransferFailed(t *testing.T) {
router := createRouter()
GetNotifierService().SetNotifierDatabaseCounter(&mockNotifierDatabaseCounter{})
userA := users_testing.CreateTestUser(users_enums.UserRoleMember)
userB := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace1 := workspaces_testing.CreateTestWorkspace("Workspace 1", userA, router)
workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", userB, router)
notifier := createNewNotifier(workspace1.ID)
var savedNotifier Notifier
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/notifiers",
"Bearer "+userA.Token,
*notifier,
http.StatusOK,
&savedNotifier,
)
request := TransferNotifierRequest{
TargetWorkspaceID: workspace2.ID,
}
testResp := test_utils.MakePostRequest(
t,
router,
fmt.Sprintf("/api/v1/notifiers/%s/transfer", savedNotifier.ID.String()),
"Bearer "+userA.Token,
request,
http.StatusForbidden,
)
assert.Contains(
t,
string(testResp.Body),
"insufficient permissions to manage notifier in target workspace",
)
deleteNotifier(t, router, savedNotifier.ID, workspace1.ID, userA.Token)
workspaces_testing.RemoveTestWorkspace(workspace1, router)
workspaces_testing.RemoveTestWorkspace(workspace2, router)
}
type mockNotifierDatabaseCounter struct{}
func (m *mockNotifierDatabaseCounter) GetNotifierAttachedDatabasesIDs(
notifierID uuid.UUID,
) ([]uuid.UUID, error) {
return []uuid.UUID{}, nil
}
func createRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -979,6 +1162,7 @@ func createRouter() *gin.Engine {
}
audit_logs.SetupDependencies()
GetNotifierService().SetNotifierDatabaseCounter(&mockNotifierDatabaseCounter{})
return router
}

View File

@@ -14,6 +14,7 @@ var notifierService = &NotifierService{
workspaces_services.GetWorkspaceService(),
audit_logs.GetAuditLogService(),
encryption.GetFieldEncryptor(),
nil,
}
var notifierController = &NotifierController{
notifierService,

View File

@@ -0,0 +1,7 @@
package notifiers
import "github.com/google/uuid"
type TransferNotifierRequest struct {
TargetWorkspaceID uuid.UUID `json:"targetWorkspaceId" binding:"required"`
}

View File

@@ -0,0 +1,36 @@
package notifiers
import "errors"
var (
ErrInsufficientPermissionsToManageNotifier = errors.New(
"insufficient permissions to manage notifier in this workspace",
)
ErrInsufficientPermissionsToViewNotifier = errors.New(
"insufficient permissions to view notifier in this workspace",
)
ErrInsufficientPermissionsToViewNotifiers = errors.New(
"insufficient permissions to view notifiers in this workspace",
)
ErrInsufficientPermissionsToTestNotifier = errors.New(
"insufficient permissions to test notifier in this workspace",
)
ErrNotifierDoesNotBelongToWorkspace = errors.New(
"notifier does not belong to this workspace",
)
ErrInsufficientPermissionsInSourceWorkspace = errors.New(
"insufficient permissions to manage notifier in source workspace",
)
ErrInsufficientPermissionsInTargetWorkspace = errors.New(
"insufficient permissions to manage notifier in target workspace",
)
ErrNotifierHasAttachedDatabases = errors.New(
"notifier has attached databases and cannot be deleted",
)
ErrNotifierHasAttachedDatabasesCannotTransfer = errors.New(
"notifier has attached databases and cannot be transferred",
)
ErrNotifierHasOtherAttachedDatabasesCannotTransfer = errors.New(
"notifier has other attached databases and cannot be transferred",
)
)

View File

@@ -3,6 +3,8 @@ package notifiers
import (
"databasus-backend/internal/util/encryption"
"log/slog"
"github.com/google/uuid"
)
type NotificationSender interface {
@@ -19,3 +21,7 @@ type NotificationSender interface {
EncryptSensitiveData(encryptor encryption.FieldEncryptor) error
}
type NotifierDatabaseCounter interface {
GetNotifierAttachedDatabasesIDs(notifierID uuid.UUID) ([]uuid.UUID, error)
}

View File

@@ -1,7 +1,6 @@
package notifiers
import (
"errors"
"fmt"
"log/slog"
@@ -14,11 +13,18 @@ import (
)
type NotifierService struct {
notifierRepository *NotifierRepository
logger *slog.Logger
workspaceService *workspaces_services.WorkspaceService
auditLogService *audit_logs.AuditLogService
fieldEncryptor encryption.FieldEncryptor
notifierRepository *NotifierRepository
logger *slog.Logger
workspaceService *workspaces_services.WorkspaceService
auditLogService *audit_logs.AuditLogService
fieldEncryptor encryption.FieldEncryptor
notifierDatabaseCounter NotifierDatabaseCounter
}
func (s *NotifierService) SetNotifierDatabaseCounter(
notifierDatabaseCounter NotifierDatabaseCounter,
) {
s.notifierDatabaseCounter = notifierDatabaseCounter
}
func (s *NotifierService) SaveNotifier(
@@ -31,7 +37,7 @@ func (s *NotifierService) SaveNotifier(
return err
}
if !canManage {
return errors.New("insufficient permissions to manage notifier in this workspace")
return ErrInsufficientPermissionsToManageNotifier
}
isUpdate := notifier.ID != uuid.Nil
@@ -43,7 +49,7 @@ func (s *NotifierService) SaveNotifier(
}
if existingNotifier.WorkspaceID != workspaceID {
return errors.New("notifier does not belong to this workspace")
return ErrNotifierDoesNotBelongToWorkspace
}
existingNotifier.Update(notifier)
@@ -106,7 +112,17 @@ func (s *NotifierService) DeleteNotifier(
return err
}
if !canManage {
return errors.New("insufficient permissions to manage notifier in this workspace")
return ErrInsufficientPermissionsToManageNotifier
}
attachedDatabasesIDs, err := s.notifierDatabaseCounter.GetNotifierAttachedDatabasesIDs(
notifier.ID,
)
if err != nil {
return err
}
if len(attachedDatabasesIDs) > 0 {
return ErrNotifierHasAttachedDatabases
}
err = s.notifierRepository.Delete(notifier)
@@ -137,13 +153,17 @@ func (s *NotifierService) GetNotifier(
return nil, err
}
if !canView {
return nil, errors.New("insufficient permissions to view notifier in this workspace")
return nil, ErrInsufficientPermissionsToViewNotifier
}
notifier.HideSensitiveData()
return notifier, nil
}
func (s *NotifierService) GetNotifierByID(id uuid.UUID) (*Notifier, error) {
return s.notifierRepository.FindByID(id)
}
func (s *NotifierService) GetNotifiers(
user *users_models.User,
workspaceID uuid.UUID,
@@ -153,7 +173,7 @@ func (s *NotifierService) GetNotifiers(
return nil, err
}
if !canView {
return nil, errors.New("insufficient permissions to view notifiers in this workspace")
return nil, ErrInsufficientPermissionsToViewNotifiers
}
notifiers, err := s.notifierRepository.FindByWorkspaceID(workspaceID)
@@ -182,7 +202,7 @@ func (s *NotifierService) SendTestNotification(
return err
}
if !canView {
return errors.New("insufficient permissions to test notifier in this workspace")
return ErrInsufficientPermissionsToTestNotifier
}
err = notifier.Send(s.fieldEncryptor, s.logger, "Test message", "This is a test message")
@@ -210,7 +230,7 @@ func (s *NotifierService) SendTestNotificationToNotifier(
}
if existingNotifier.WorkspaceID != notifier.WorkspaceID {
return errors.New("notifier does not belong to this workspace")
return ErrNotifierDoesNotBelongToWorkspace
}
existingNotifier.Update(notifier)
@@ -269,6 +289,70 @@ func (s *NotifierService) SendNotification(
}
}
func (s *NotifierService) TransferNotifierToWorkspace(
user *users_models.User,
notifierID uuid.UUID,
targetWorkspaceID uuid.UUID,
transferingWithDbID *uuid.UUID,
) error {
existingNotifier, err := s.notifierRepository.FindByID(notifierID)
if err != nil {
return err
}
canManageSource, err := s.workspaceService.CanUserManageDBs(existingNotifier.WorkspaceID, user)
if err != nil {
return err
}
if !canManageSource {
return ErrInsufficientPermissionsInSourceWorkspace
}
canManageTarget, err := s.workspaceService.CanUserManageDBs(targetWorkspaceID, user)
if err != nil {
return err
}
if !canManageTarget {
return ErrInsufficientPermissionsInTargetWorkspace
}
attachedDatabasesIDs, err := s.notifierDatabaseCounter.GetNotifierAttachedDatabasesIDs(
existingNotifier.ID,
)
if err != nil {
return err
}
if transferingWithDbID != nil {
for _, dbID := range attachedDatabasesIDs {
if dbID != *transferingWithDbID {
return ErrNotifierHasOtherAttachedDatabasesCannotTransfer
}
}
} else {
if len(attachedDatabasesIDs) > 0 {
return ErrNotifierHasAttachedDatabasesCannotTransfer
}
}
sourceWorkspaceID := existingNotifier.WorkspaceID
existingNotifier.WorkspaceID = targetWorkspaceID
_, err = s.notifierRepository.Save(existingNotifier)
if err != nil {
return err
}
s.auditLogService.WriteAuditLog(
fmt.Sprintf("Notifier transferred: %s from workspace %s to workspace %s",
existingNotifier.Name, sourceWorkspaceID, targetWorkspaceID),
&user.ID,
&targetWorkspaceID,
)
return nil
}
func (s *NotifierService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error {
notifiers, err := s.notifierRepository.FindByWorkspaceID(workspaceID)
if err != nil {

View File

@@ -1,6 +1,8 @@
package storages
import (
"errors"
users_middleware "databasus-backend/internal/features/users/middleware"
workspaces_services "databasus-backend/internal/features/workspaces/services"
"net/http"
@@ -20,6 +22,7 @@ func (c *StorageController) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/storages/:id", c.GetStorage)
router.DELETE("/storages/:id", c.DeleteStorage)
router.POST("/storages/:id/test", c.TestStorageConnection)
router.POST("/storages/:id/transfer", c.TransferStorageToWorkspace)
router.POST("/storages/direct-test", c.TestStorageConnectionDirect)
}
@@ -55,7 +58,7 @@ func (c *StorageController) SaveStorage(ctx *gin.Context) {
}
if err := c.storageService.SaveStorage(user, request.WorkspaceID, &request); err != nil {
if err.Error() == "insufficient permissions to manage storage in this workspace" {
if errors.Is(err, ErrInsufficientPermissionsToManageStorage) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -93,7 +96,7 @@ func (c *StorageController) GetStorage(ctx *gin.Context) {
storage, err := c.storageService.GetStorage(user, id)
if err != nil {
if err.Error() == "insufficient permissions to view storage in this workspace" {
if errors.Is(err, ErrInsufficientPermissionsToViewStorage) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -137,7 +140,7 @@ func (c *StorageController) GetStorages(ctx *gin.Context) {
storages, err := c.storageService.GetStorages(user, workspaceID)
if err != nil {
if err.Error() == "insufficient permissions to view storages in this workspace" {
if errors.Is(err, ErrInsufficientPermissionsToViewStorages) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -174,7 +177,7 @@ func (c *StorageController) DeleteStorage(ctx *gin.Context) {
}
if err := c.storageService.DeleteStorage(user, id); err != nil {
if err.Error() == "insufficient permissions to manage storage in this workspace" {
if errors.Is(err, ErrInsufficientPermissionsToManageStorage) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -211,7 +214,7 @@ func (c *StorageController) TestStorageConnection(ctx *gin.Context) {
}
if err := c.storageService.TestStorageConnection(user, id); err != nil {
if err.Error() == "insufficient permissions to test storage in this workspace" {
if errors.Is(err, ErrInsufficientPermissionsToTestStorage) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -222,6 +225,57 @@ func (c *StorageController) TestStorageConnection(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"message": "storage connection test successful"})
}
// TransferStorageToWorkspace
// @Summary Transfer storage to another workspace
// @Description Transfer a storage from one workspace to another
// @Tags storages
// @Accept json
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param id path string true "Storage ID"
// @Param request body TransferStorageRequest true "Target workspace ID"
// @Success 200
// @Failure 400
// @Failure 401
// @Failure 403
// @Router /storages/{id}/transfer [post]
func (c *StorageController) TransferStorageToWorkspace(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 storage ID"})
return
}
var request TransferStorageRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if request.TargetWorkspaceID == uuid.Nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId is required"})
return
}
if err := c.storageService.TransferStorageToWorkspace(user, id, request.TargetWorkspaceID, nil); err != nil {
if errors.Is(err, ErrInsufficientPermissionsInSourceWorkspace) ||
errors.Is(err, ErrInsufficientPermissionsInTargetWorkspace) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "storage transferred successfully"})
}
// TestStorageConnectionDirect
// @Summary Test storage connection directly
// @Description Test the connection to a storage object provided in the request

View File

@@ -29,6 +29,14 @@ import (
"github.com/stretchr/testify/assert"
)
type mockStorageDatabaseCounter struct{}
func (m *mockStorageDatabaseCounter) GetStorageAttachedDatabasesIDs(
storageID uuid.UUID,
) ([]uuid.UUID, error) {
return []uuid.UUID{}, nil
}
func Test_SaveNewStorage_StorageReturnedViaGet(t *testing.T) {
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
router := createRouter()
@@ -200,161 +208,161 @@ func Test_TestExistingStorageConnection_ConnectionEstablished(t *testing.T) {
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func Test_ViewerCanViewStorages_ButCannotModify(t *testing.T) {
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
viewer := users_testing.CreateTestUser(users_enums.UserRoleMember)
router := createRouter()
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
workspaces_testing.AddMemberToWorkspace(
workspace,
viewer,
users_enums.WorkspaceRoleViewer,
owner.Token,
router,
)
storage := createNewStorage(workspace.ID)
func Test_WorkspaceRolePermissions(t *testing.T) {
tests := []struct {
name string
workspaceRole *users_enums.WorkspaceRole
isGlobalAdmin bool
canCreate bool
canUpdate bool
canDelete bool
}{
{
name: "owner can manage storages",
workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(),
isGlobalAdmin: false,
canCreate: true,
canUpdate: true,
canDelete: true,
},
{
name: "admin can manage storages",
workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(),
isGlobalAdmin: false,
canCreate: true,
canUpdate: true,
canDelete: true,
},
{
name: "member can manage storages",
workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(),
isGlobalAdmin: false,
canCreate: true,
canUpdate: true,
canDelete: true,
},
{
name: "viewer can view but cannot modify storages",
workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(),
isGlobalAdmin: false,
canCreate: false,
canUpdate: false,
canDelete: false,
},
{
name: "global admin can manage storages",
workspaceRole: nil,
isGlobalAdmin: true,
canCreate: true,
canUpdate: true,
canDelete: true,
},
}
var savedStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+owner.Token,
*storage,
http.StatusOK,
&savedStorage,
)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := createRouter()
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
// Viewer can GET storages
var storages []Storage
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/storages?workspace_id=%s", workspace.ID.String()),
"Bearer "+viewer.Token,
http.StatusOK,
&storages,
)
assert.Len(t, storages, 1)
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
// Viewer cannot CREATE storage
newStorage := createNewStorage(workspace.ID)
test_utils.MakePostRequest(
t, router, "/api/v1/storages", "Bearer "+viewer.Token, *newStorage, http.StatusForbidden,
)
var testUserToken string
if tt.isGlobalAdmin {
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
testUserToken = admin.Token
} else if tt.workspaceRole != nil && *tt.workspaceRole == users_enums.WorkspaceRoleOwner {
testUserToken = owner.Token
} else if tt.workspaceRole != nil {
testUser := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspaces_testing.AddMemberToWorkspace(
workspace,
testUser,
*tt.workspaceRole,
owner.Token,
router,
)
testUserToken = testUser.Token
}
// Viewer cannot UPDATE storage
savedStorage.Name = "Updated by viewer"
test_utils.MakePostRequest(
t, router, "/api/v1/storages", "Bearer "+viewer.Token, savedStorage, http.StatusForbidden,
)
// Owner creates initial storage for all test cases
var ownerStorage Storage
storage := createNewStorage(workspace.ID)
test_utils.MakePostRequestAndUnmarshal(
t, router, "/api/v1/storages", "Bearer "+owner.Token,
*storage, http.StatusOK, &ownerStorage,
)
// Viewer cannot DELETE storage
test_utils.MakeDeleteRequest(
t,
router,
fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()),
"Bearer "+viewer.Token,
http.StatusForbidden,
)
// Test GET storages
var storages []Storage
test_utils.MakeGetRequestAndUnmarshal(
t, router,
fmt.Sprintf("/api/v1/storages?workspace_id=%s", workspace.ID.String()),
"Bearer "+testUserToken, http.StatusOK, &storages,
)
assert.Len(t, storages, 1)
deleteStorage(t, router, savedStorage.ID, workspace.ID, owner.Token)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
// Test CREATE storage
createStatusCode := http.StatusOK
if !tt.canCreate {
createStatusCode = http.StatusForbidden
}
newStorage := createNewStorage(workspace.ID)
var savedStorage Storage
if tt.canCreate {
test_utils.MakePostRequestAndUnmarshal(
t, router, "/api/v1/storages", "Bearer "+testUserToken,
*newStorage, createStatusCode, &savedStorage,
)
assert.NotEmpty(t, savedStorage.ID)
} else {
test_utils.MakePostRequest(
t, router, "/api/v1/storages", "Bearer "+testUserToken,
*newStorage, createStatusCode,
)
}
func Test_MemberCanManageStorages(t *testing.T) {
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
member := users_testing.CreateTestUser(users_enums.UserRoleMember)
router := createRouter()
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
workspaces_testing.AddMemberToWorkspace(
workspace,
member,
users_enums.WorkspaceRoleMember,
owner.Token,
router,
)
storage := createNewStorage(workspace.ID)
// Test UPDATE storage
updateStatusCode := http.StatusOK
if !tt.canUpdate {
updateStatusCode = http.StatusForbidden
}
ownerStorage.Name = "Updated by test user"
if tt.canUpdate {
var updatedStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t, router, "/api/v1/storages", "Bearer "+testUserToken,
ownerStorage, updateStatusCode, &updatedStorage,
)
assert.Equal(t, "Updated by test user", updatedStorage.Name)
} else {
test_utils.MakePostRequest(
t, router, "/api/v1/storages", "Bearer "+testUserToken,
ownerStorage, updateStatusCode,
)
}
// Member can CREATE storage
var savedStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+member.Token,
*storage,
http.StatusOK,
&savedStorage,
)
assert.NotEmpty(t, savedStorage.ID)
// Test DELETE storage
deleteStatusCode := http.StatusOK
if !tt.canDelete {
deleteStatusCode = http.StatusForbidden
}
test_utils.MakeDeleteRequest(
t, router,
fmt.Sprintf("/api/v1/storages/%s", ownerStorage.ID.String()),
"Bearer "+testUserToken, deleteStatusCode,
)
// Member can UPDATE storage
savedStorage.Name = "Updated by member"
var updatedStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+member.Token,
savedStorage,
http.StatusOK,
&updatedStorage,
)
assert.Equal(t, "Updated by member", updatedStorage.Name)
// Member can DELETE storage
test_utils.MakeDeleteRequest(
t,
router,
fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()),
"Bearer "+member.Token,
http.StatusOK,
)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func Test_AdminCanManageStorages(t *testing.T) {
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
admin := users_testing.CreateTestUser(users_enums.UserRoleMember)
router := createRouter()
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
workspaces_testing.AddMemberToWorkspace(
workspace,
admin,
users_enums.WorkspaceRoleAdmin,
owner.Token,
router,
)
storage := createNewStorage(workspace.ID)
// Admin can CREATE, UPDATE, DELETE
var savedStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+admin.Token,
*storage,
http.StatusOK,
&savedStorage,
)
savedStorage.Name = "Updated by admin"
test_utils.MakePostRequest(
t, router, "/api/v1/storages", "Bearer "+admin.Token, savedStorage, http.StatusOK,
)
test_utils.MakeDeleteRequest(
t,
router,
fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()),
"Bearer "+admin.Token,
http.StatusOK,
)
workspaces_testing.RemoveTestWorkspace(workspace, router)
// Cleanup
if tt.canCreate {
deleteStorage(t, router, savedStorage.ID, workspace.ID, owner.Token)
}
if !tt.canDelete {
deleteStorage(t, router, ownerStorage.ID, workspace.ID, owner.Token)
}
workspaces_testing.RemoveTestWorkspace(workspace, router)
})
}
}
func Test_UserNotInWorkspace_CannotAccessStorages(t *testing.T) {
@@ -975,6 +983,184 @@ func Test_StorageSensitiveDataLifecycle_AllTypes(t *testing.T) {
}
}
func Test_TransferStorage_PermissionsEnforced(t *testing.T) {
tests := []struct {
name string
sourceRole *users_enums.WorkspaceRole
targetRole *users_enums.WorkspaceRole
isGlobalAdmin bool
expectSuccess bool
expectedStatusCode int
}{
{
name: "owner in both workspaces can transfer",
sourceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(),
targetRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(),
isGlobalAdmin: false,
expectSuccess: true,
expectedStatusCode: http.StatusOK,
},
{
name: "admin in both workspaces can transfer",
sourceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(),
targetRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(),
isGlobalAdmin: false,
expectSuccess: true,
expectedStatusCode: http.StatusOK,
},
{
name: "member in both workspaces can transfer",
sourceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(),
targetRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(),
isGlobalAdmin: false,
expectSuccess: true,
expectedStatusCode: http.StatusOK,
},
{
name: "viewer in both workspaces cannot transfer",
sourceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(),
targetRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(),
isGlobalAdmin: false,
expectSuccess: false,
expectedStatusCode: http.StatusForbidden,
},
{
name: "global admin can transfer",
sourceRole: nil,
targetRole: nil,
isGlobalAdmin: true,
expectSuccess: true,
expectedStatusCode: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := createRouter()
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
sourceOwner := users_testing.CreateTestUser(users_enums.UserRoleMember)
targetOwner := users_testing.CreateTestUser(users_enums.UserRoleMember)
sourceWorkspace := workspaces_testing.CreateTestWorkspace(
"Source Workspace",
sourceOwner,
router,
)
targetWorkspace := workspaces_testing.CreateTestWorkspace(
"Target Workspace",
targetOwner,
router,
)
storage := createNewStorage(sourceWorkspace.ID)
var savedStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+sourceOwner.Token,
*storage,
http.StatusOK,
&savedStorage,
)
var testUserToken string
if tt.isGlobalAdmin {
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
testUserToken = admin.Token
} else if tt.sourceRole != nil {
testUser := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspaces_testing.AddMemberToWorkspace(sourceWorkspace, testUser, *tt.sourceRole, sourceOwner.Token, router)
workspaces_testing.AddMemberToWorkspace(targetWorkspace, testUser, *tt.targetRole, targetOwner.Token, router)
testUserToken = testUser.Token
}
request := TransferStorageRequest{
TargetWorkspaceID: targetWorkspace.ID,
}
testResp := test_utils.MakePostRequest(
t,
router,
fmt.Sprintf("/api/v1/storages/%s/transfer", savedStorage.ID.String()),
"Bearer "+testUserToken,
request,
tt.expectedStatusCode,
)
if tt.expectSuccess {
assert.Contains(t, string(testResp.Body), "transferred successfully")
var retrievedStorage Storage
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()),
"Bearer "+targetOwner.Token,
http.StatusOK,
&retrievedStorage,
)
assert.Equal(t, targetWorkspace.ID, retrievedStorage.WorkspaceID)
deleteStorage(t, router, savedStorage.ID, targetWorkspace.ID, targetOwner.Token)
} else {
assert.Contains(t, string(testResp.Body), "insufficient permissions")
deleteStorage(t, router, savedStorage.ID, sourceWorkspace.ID, sourceOwner.Token)
}
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
})
}
}
func Test_TransferStorageNotManagableWorkspace_TransferFailed(t *testing.T) {
router := createRouter()
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
userA := users_testing.CreateTestUser(users_enums.UserRoleMember)
userB := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace1 := workspaces_testing.CreateTestWorkspace("Workspace 1", userA, router)
workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", userB, router)
storage := createNewStorage(workspace1.ID)
var savedStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+userA.Token,
*storage,
http.StatusOK,
&savedStorage,
)
request := TransferStorageRequest{
TargetWorkspaceID: workspace2.ID,
}
testResp := test_utils.MakePostRequest(
t,
router,
fmt.Sprintf("/api/v1/storages/%s/transfer", savedStorage.ID.String()),
"Bearer "+userA.Token,
request,
http.StatusForbidden,
)
assert.Contains(
t,
string(testResp.Body),
"insufficient permissions to manage storage in target workspace",
)
deleteStorage(t, router, savedStorage.ID, workspace1.ID, userA.Token)
workspaces_testing.RemoveTestWorkspace(workspace1, router)
workspaces_testing.RemoveTestWorkspace(workspace2, router)
}
func createRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -989,6 +1175,7 @@ func createRouter() *gin.Engine {
}
audit_logs.SetupDependencies()
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
return router
}

View File

@@ -12,6 +12,7 @@ var storageService = &StorageService{
workspaces_services.GetWorkspaceService(),
audit_logs.GetAuditLogService(),
encryption.GetFieldEncryptor(),
nil,
}
var storageController = &StorageController{
storageService,

View File

@@ -0,0 +1,7 @@
package storages
import "github.com/google/uuid"
type TransferStorageRequest struct {
TargetWorkspaceID uuid.UUID `json:"targetWorkspaceId" binding:"required"`
}

View File

@@ -0,0 +1,36 @@
package storages
import "errors"
var (
ErrInsufficientPermissionsToManageStorage = errors.New(
"insufficient permissions to manage storage in this workspace",
)
ErrInsufficientPermissionsToViewStorage = errors.New(
"insufficient permissions to view storage in this workspace",
)
ErrInsufficientPermissionsToViewStorages = errors.New(
"insufficient permissions to view storages in this workspace",
)
ErrInsufficientPermissionsToTestStorage = errors.New(
"insufficient permissions to test storage in this workspace",
)
ErrInsufficientPermissionsInSourceWorkspace = errors.New(
"insufficient permissions to manage storage in source workspace",
)
ErrInsufficientPermissionsInTargetWorkspace = errors.New(
"insufficient permissions to manage storage in target workspace",
)
ErrStorageDoesNotBelongToWorkspace = errors.New(
"storage does not belong to this workspace",
)
ErrStorageHasAttachedDatabases = errors.New(
"storage has attached databases and cannot be deleted",
)
ErrStorageHasAttachedDatabasesCannotTransfer = errors.New(
"storage has attached databases and cannot be transferred",
)
ErrStorageHasOtherAttachedDatabasesCannotTransfer = errors.New(
"storage has other attached databases and cannot be transferred",
)
)

View File

@@ -30,3 +30,7 @@ type StorageFileSaver interface {
EncryptSensitiveData(encryptor encryption.FieldEncryptor) error
}
type StorageDatabaseCounter interface {
GetStorageAttachedDatabasesIDs(storageID uuid.UUID) ([]uuid.UUID, error)
}

View File

@@ -356,7 +356,7 @@ func (s *S3Storage) Update(incoming *S3Storage) {
}
// we do not allow to change the prefix after creation,
// otherwise we will have to migrate all the data to the new prefix
// otherwise we will have to transfer all the data to the new prefix
}
func (s *S3Storage) buildObjectKey(fileName string) string {

View File

@@ -1,7 +1,6 @@
package storages
import (
"errors"
"fmt"
audit_logs "databasus-backend/internal/features/audit_logs"
@@ -13,10 +12,15 @@ import (
)
type StorageService struct {
storageRepository *StorageRepository
workspaceService *workspaces_services.WorkspaceService
auditLogService *audit_logs.AuditLogService
fieldEncryptor encryption.FieldEncryptor
storageRepository *StorageRepository
workspaceService *workspaces_services.WorkspaceService
auditLogService *audit_logs.AuditLogService
fieldEncryptor encryption.FieldEncryptor
storageDatabaseCounter StorageDatabaseCounter
}
func (s *StorageService) SetStorageDatabaseCounter(storageDatabaseCounter StorageDatabaseCounter) {
s.storageDatabaseCounter = storageDatabaseCounter
}
func (s *StorageService) SaveStorage(
@@ -29,7 +33,7 @@ func (s *StorageService) SaveStorage(
return err
}
if !canManage {
return errors.New("insufficient permissions to manage storage in this workspace")
return ErrInsufficientPermissionsToManageStorage
}
isUpdate := storage.ID != uuid.Nil
@@ -41,7 +45,7 @@ func (s *StorageService) SaveStorage(
}
if existingStorage.WorkspaceID != workspaceID {
return errors.New("storage does not belong to this workspace")
return ErrStorageDoesNotBelongToWorkspace
}
existingStorage.Update(storage)
@@ -104,7 +108,15 @@ func (s *StorageService) DeleteStorage(
return err
}
if !canManage {
return errors.New("insufficient permissions to manage storage in this workspace")
return ErrInsufficientPermissionsToManageStorage
}
attachedDatabasesIDs, err := s.storageDatabaseCounter.GetStorageAttachedDatabasesIDs(storage.ID)
if err != nil {
return err
}
if len(attachedDatabasesIDs) > 0 {
return ErrStorageHasAttachedDatabases
}
err = s.storageRepository.Delete(storage)
@@ -135,7 +147,7 @@ func (s *StorageService) GetStorage(
return nil, err
}
if !canView {
return nil, errors.New("insufficient permissions to view storage in this workspace")
return nil, ErrInsufficientPermissionsToViewStorage
}
storage.HideSensitiveData()
@@ -152,7 +164,7 @@ func (s *StorageService) GetStorages(
return nil, err
}
if !canView {
return nil, errors.New("insufficient permissions to view storages in this workspace")
return nil, ErrInsufficientPermissionsToViewStorages
}
storages, err := s.storageRepository.FindByWorkspaceID(workspaceID)
@@ -181,7 +193,7 @@ func (s *StorageService) TestStorageConnection(
return err
}
if !canView {
return errors.New("insufficient permissions to test storage in this workspace")
return ErrInsufficientPermissionsToTestStorage
}
err = storage.TestConnection(s.fieldEncryptor)
@@ -212,7 +224,7 @@ func (s *StorageService) TestStorageConnectionDirect(
}
if existingStorage.WorkspaceID != storage.WorkspaceID {
return errors.New("storage does not belong to this workspace")
return ErrStorageDoesNotBelongToWorkspace
}
existingStorage.Update(storage)
@@ -235,6 +247,70 @@ func (s *StorageService) GetStorageByID(
return s.storageRepository.FindByID(id)
}
func (s *StorageService) TransferStorageToWorkspace(
user *users_models.User,
storageID uuid.UUID,
targetWorkspaceID uuid.UUID,
transferingWithDbID *uuid.UUID,
) error {
existingStorage, err := s.storageRepository.FindByID(storageID)
if err != nil {
return err
}
canManageSource, err := s.workspaceService.CanUserManageDBs(existingStorage.WorkspaceID, user)
if err != nil {
return err
}
if !canManageSource {
return ErrInsufficientPermissionsInSourceWorkspace
}
canManageTarget, err := s.workspaceService.CanUserManageDBs(targetWorkspaceID, user)
if err != nil {
return err
}
if !canManageTarget {
return ErrInsufficientPermissionsInTargetWorkspace
}
attachedDatabasesIDs, err := s.storageDatabaseCounter.GetStorageAttachedDatabasesIDs(
existingStorage.ID,
)
if err != nil {
return err
}
if transferingWithDbID != nil {
for _, dbID := range attachedDatabasesIDs {
if dbID != *transferingWithDbID {
return ErrStorageHasOtherAttachedDatabasesCannotTransfer
}
}
} else {
if len(attachedDatabasesIDs) > 0 {
return ErrStorageHasAttachedDatabasesCannotTransfer
}
}
sourceWorkspaceID := existingStorage.WorkspaceID
existingStorage.WorkspaceID = targetWorkspaceID
_, err = s.storageRepository.Save(existingStorage)
if err != nil {
return err
}
s.auditLogService.WriteAuditLog(
fmt.Sprintf("Storage transferred: %s from workspace %s to workspace %s",
existingStorage.Name, sourceWorkspaceID, targetWorkspaceID),
&user.ID,
&targetWorkspaceID,
)
return nil
}
func (s *StorageService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error {
storages, err := s.storageRepository.FindByWorkspaceID(workspaceID)
if err != nil {

View File

@@ -405,13 +405,17 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string, cp
backup := waitForBackupCompletion(t, router, database.ID, user.Token, 5*time.Minute)
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
newDBName := fmt.Sprintf("restoreddb_%s_cpu%d", pgVersion, cpuCount)
newDBName := fmt.Sprintf("restoreddb_%s_cpu%d_%s", pgVersion, cpuCount, uuid.New().String()[:8])
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
defer func() {
_, _ = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
}()
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, container.Username, container.Password, newDBName)
newDB, err := sqlx.Connect("postgres", newDSN)
@@ -511,13 +515,17 @@ func testSchemaSelectionAllSchemasForVersion(t *testing.T, pgVersion string, por
backup := waitForBackupCompletion(t, router, database.ID, user.Token, 5*time.Minute)
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
newDBName := "restored_all_schemas_" + pgVersion
newDBName := fmt.Sprintf("restored_all_schemas_%s_%s", pgVersion, uuid.New().String()[:8])
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
defer func() {
_, _ = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
}()
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, container.Username, container.Password, newDBName)
newDB, err := sqlx.Connect("postgres", newDSN)
@@ -635,14 +643,17 @@ func testBackupRestoreWithExcludeExtensionsForVersion(t *testing.T, pgVersion st
backup := waitForBackupCompletion(t, router, database.ID, user.Token, 5*time.Minute)
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
// Create new database for restore with extension pre-installed
newDBName := "restored_exclude_ext_" + pgVersion
newDBName := fmt.Sprintf("restored_exclude_ext_%s_%s", pgVersion, uuid.New().String()[:8])
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
defer func() {
_, _ = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
}()
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, container.Username, container.Password, newDBName)
newDB, err := sqlx.Connect("postgres", newDSN)
@@ -766,14 +777,17 @@ func testBackupRestoreWithoutExcludeExtensionsForVersion(
backup := waitForBackupCompletion(t, router, database.ID, user.Token, 5*time.Minute)
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
// Create new database for restore WITHOUT pre-installed extension
newDBName := "restored_with_ext_" + pgVersion
newDBName := fmt.Sprintf("restored_with_ext_%s_%s", pgVersion, uuid.New().String()[:8])
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
defer func() {
_, _ = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
}()
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, container.Username, container.Password, newDBName)
newDB, err := sqlx.Connect("postgres", newDSN)
@@ -897,13 +911,17 @@ func testBackupRestoreWithReadOnlyUserForVersion(t *testing.T, pgVersion string,
backup := waitForBackupCompletion(t, router, updatedDatabase.ID, user.Token, 5*time.Minute)
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
newDBName := "restoreddb_readonly"
newDBName := fmt.Sprintf("restoreddb_readonly_%s", uuid.New().String()[:8])
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
defer func() {
_, _ = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
}()
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, container.Username, container.Password, newDBName)
newDB, err := sqlx.Connect("postgres", newDSN)
@@ -1006,13 +1024,17 @@ func testSchemaSelectionOnlySpecifiedSchemasForVersion(
backup := waitForBackupCompletion(t, router, database.ID, user.Token, 5*time.Minute)
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
newDBName := "restored_specific_schemas_" + pgVersion
newDBName := fmt.Sprintf("restored_specific_schemas_%s_%s", pgVersion, uuid.New().String()[:8])
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
defer func() {
_, _ = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
}()
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, container.Username, container.Password, newDBName)
newDB, err := sqlx.Connect("postgres", newDSN)
@@ -1111,13 +1133,17 @@ func testBackupRestoreWithEncryptionForVersion(t *testing.T, pgVersion string, p
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
assert.Equal(t, backups_config.BackupEncryptionEncrypted, backup.Encryption)
newDBName := "restoreddb_encrypted"
newDBName := fmt.Sprintf("restoreddb_encrypted_%s", uuid.New().String()[:8])
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
defer func() {
_, _ = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
}()
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, container.Username, container.Password, newDBName)
newDB, err := sqlx.Connect("postgres", newDSN)

View File

@@ -1,10 +1,12 @@
package users_controllers
import (
"errors"
"net/http"
"databasus-backend/internal/config"
user_dto "databasus-backend/internal/features/users/dto"
users_errors "databasus-backend/internal/features/users/errors"
user_middleware "databasus-backend/internal/features/users/middleware"
users_services "databasus-backend/internal/features/users/services"
@@ -206,7 +208,7 @@ func (c *UserController) InviteUser(ctx *gin.Context) {
response, err := c.userService.InviteUser(&request, user)
if err != nil {
if err.Error() == "insufficient permissions to invite users" {
if errors.Is(err, users_errors.ErrInsufficientPermissionsToInviteUsers) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}

View File

@@ -0,0 +1,7 @@
package users_errors
import "errors"
var (
ErrInsufficientPermissionsToInviteUsers = errors.New("insufficient permissions to invite users")
)

View File

@@ -20,6 +20,7 @@ import (
"databasus-backend/internal/features/encryption/secrets"
users_dto "databasus-backend/internal/features/users/dto"
users_enums "databasus-backend/internal/features/users/enums"
users_errors "databasus-backend/internal/features/users/errors"
users_interfaces "databasus-backend/internal/features/users/interfaces"
users_models "databasus-backend/internal/features/users/models"
users_repositories "databasus-backend/internal/features/users/repositories"
@@ -340,7 +341,7 @@ func (s *UserService) InviteUser(
// Check if user has permission to invite
if !invitedBy.CanInviteUsers(settings) {
return nil, errors.New("insufficient permissions to invite users")
return nil, users_errors.ErrInsufficientPermissionsToInviteUsers
}
// Check if user already exists

View File

@@ -1,10 +1,12 @@
package workspaces_controllers
import (
"errors"
"net/http"
users_middleware "databasus-backend/internal/features/users/middleware"
workspaces_dto "databasus-backend/internal/features/workspaces/dto"
workspaces_errors "databasus-backend/internal/features/workspaces/errors"
workspaces_services "databasus-backend/internal/features/workspaces/services"
"github.com/gin-gonic/gin"
@@ -53,7 +55,7 @@ func (c *MembershipController) ListMembers(ctx *gin.Context) {
response, err := c.membershipService.GetMembers(workspaceID, user)
if err != nil {
if err.Error() == "insufficient permissions to view workspace members" {
if errors.Is(err, workspaces_errors.ErrInsufficientPermissionsToViewMembers) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -105,8 +107,8 @@ func (c *MembershipController) AddMember(ctx *gin.Context) {
response, err := c.membershipService.AddMember(workspaceID, &request, user)
if err != nil {
if err.Error() == "insufficient permissions to manage members" ||
err.Error() == "only workspace owner can add/manage admins" {
if errors.Is(err, workspaces_errors.ErrInsufficientPermissionsToManageMembers) ||
errors.Is(err, workspaces_errors.ErrOnlyOwnerCanAddManageAdmins) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -160,8 +162,8 @@ func (c *MembershipController) ChangeMemberRole(ctx *gin.Context) {
}
if err := c.membershipService.ChangeMemberRole(workspaceID, userID, &request, user); err != nil {
if err.Error() == "insufficient permissions to manage members" ||
err.Error() == "only workspace owner can add/manage admins" {
if errors.Is(err, workspaces_errors.ErrInsufficientPermissionsToManageMembers) ||
errors.Is(err, workspaces_errors.ErrOnlyOwnerCanAddManageAdmins) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -206,8 +208,8 @@ func (c *MembershipController) RemoveMember(ctx *gin.Context) {
}
if err := c.membershipService.RemoveMember(workspaceID, userID, user); err != nil {
if err.Error() == "insufficient permissions to remove members" ||
err.Error() == "only workspace owner can remove admins" {
if errors.Is(err, workspaces_errors.ErrInsufficientPermissionsToRemoveMembers) ||
errors.Is(err, workspaces_errors.ErrOnlyOwnerCanRemoveAdmins) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -253,7 +255,7 @@ func (c *MembershipController) TransferOwnership(ctx *gin.Context) {
}
if err := c.membershipService.TransferOwnership(workspaceID, &request, user); err != nil {
if err.Error() == "only workspace owner or admin can transfer ownership" {
if errors.Is(err, workspaces_errors.ErrOnlyOwnerOrAdminCanTransferOwnership) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}

View File

@@ -1,11 +1,13 @@
package workspaces_controllers
import (
"errors"
"net/http"
audit_logs "databasus-backend/internal/features/audit_logs"
users_middleware "databasus-backend/internal/features/users/middleware"
workspaces_dto "databasus-backend/internal/features/workspaces/dto"
workspaces_errors "databasus-backend/internal/features/workspaces/errors"
workspaces_models "databasus-backend/internal/features/workspaces/models"
workspaces_services "databasus-backend/internal/features/workspaces/services"
@@ -56,7 +58,7 @@ func (c *WorkspaceController) CreateWorkspace(ctx *gin.Context) {
response, err := c.workspaceService.CreateWorkspace(&request, user)
if err != nil {
if err.Error() == "insufficient permissions to create workspaces" {
if errors.Is(err, workspaces_errors.ErrInsufficientPermissionsToCreateWorkspaces) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -121,7 +123,7 @@ func (c *WorkspaceController) GetWorkspace(ctx *gin.Context) {
workspace, err := c.workspaceService.GetWorkspace(workspaceID, user)
if err != nil {
if err.Error() == "insufficient permissions to view workspace" {
if errors.Is(err, workspaces_errors.ErrInsufficientPermissionsToViewWorkspace) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -168,7 +170,7 @@ func (c *WorkspaceController) UpdateWorkspace(ctx *gin.Context) {
updatedWorkspace, err := c.workspaceService.UpdateWorkspace(workspaceID, &workspace, user)
if err != nil {
if err.Error() == "insufficient permissions to update workspace" {
if errors.Is(err, workspaces_errors.ErrInsufficientPermissionsToUpdateWorkspace) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -205,7 +207,7 @@ func (c *WorkspaceController) DeleteWorkspace(ctx *gin.Context) {
}
if err := c.workspaceService.DeleteWorkspace(workspaceID, user); err != nil {
if err.Error() == "only workspace owner or admin can delete workspace" {
if errors.Is(err, workspaces_errors.ErrOnlyOwnerOrAdminCanDeleteWorkspace) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -254,7 +256,7 @@ func (c *WorkspaceController) GetWorkspaceAuditLogs(ctx *gin.Context) {
response, err := c.workspaceService.GetWorkspaceAuditLogs(workspaceID, user, request)
if err != nil {
if err.Error() == "insufficient permissions to view workspace audit logs" {
if errors.Is(err, workspaces_errors.ErrInsufficientPermissionsToViewWorkspaceAuditLogs) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}

View File

@@ -0,0 +1,56 @@
package workspaces_errors
import "errors"
var (
// Workspace errors
ErrInsufficientPermissionsToCreateWorkspaces = errors.New(
"insufficient permissions to create workspaces",
)
ErrInsufficientPermissionsToViewWorkspace = errors.New(
"insufficient permissions to view workspace",
)
ErrInsufficientPermissionsToUpdateWorkspace = errors.New(
"insufficient permissions to update workspace",
)
ErrInsufficientPermissionsToViewWorkspaceAuditLogs = errors.New(
"insufficient permissions to view workspace audit logs",
)
ErrOnlyOwnerOrAdminCanDeleteWorkspace = errors.New(
"only workspace owner or admin can delete workspace",
)
// Membership errors
ErrInsufficientPermissionsToViewMembers = errors.New(
"insufficient permissions to view workspace members",
)
ErrInsufficientPermissionsToManageMembers = errors.New(
"insufficient permissions to manage members",
)
ErrInsufficientPermissionsToRemoveMembers = errors.New(
"insufficient permissions to remove members",
)
ErrInsufficientPermissionsToInviteUsers = errors.New(
"insufficient permissions to invite users",
)
ErrOnlyOwnerCanAddManageAdmins = errors.New(
"only workspace owner can add/manage admins",
)
ErrOnlyOwnerCanRemoveAdmins = errors.New("only workspace owner can remove admins")
ErrOnlyOwnerOrAdminCanTransferOwnership = errors.New(
"only workspace owner or admin can transfer ownership",
)
ErrUserAlreadyMember = errors.New(
"user is already a member of this workspace",
)
ErrCannotChangeOwnRole = errors.New("cannot change your own role")
ErrUserNotMemberOfWorkspace = errors.New("user is not a member of this workspace")
ErrCannotChangeOwnerRole = errors.New("cannot change owner role")
ErrUserNotFound = errors.New("user not found")
ErrCannotRemoveWorkspaceOwner = errors.New(
"cannot remove workspace owner, transfer ownership first",
)
ErrNewOwnerNotFound = errors.New("new owner not found")
ErrNewOwnerMustBeMember = errors.New("new owner must be a workspace member")
ErrNoCurrentWorkspaceOwner = errors.New("no current workspace owner found")
)

View File

@@ -1,7 +1,6 @@
package workspaces_services
import (
"errors"
"fmt"
audit_logs "databasus-backend/internal/features/audit_logs"
@@ -10,6 +9,7 @@ import (
users_models "databasus-backend/internal/features/users/models"
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_models "databasus-backend/internal/features/workspaces/models"
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
@@ -34,7 +34,7 @@ func (s *MembershipService) GetMembers(
return nil, err
}
if !canView {
return nil, errors.New("insufficient permissions to view workspace members")
return nil, workspaces_errors.ErrInsufficientPermissionsToViewMembers
}
members, err := s.membershipRepository.GetWorkspaceMembers(workspaceID)
@@ -74,7 +74,7 @@ func (s *MembershipService) AddMember(
}
if !addedBy.CanInviteUsers(settings) {
return nil, errors.New("insufficient permissions to invite users")
return nil, workspaces_errors.ErrInsufficientPermissionsToInviteUsers
}
inviteRequest := &users_dto.InviteUserRequestDTO{
@@ -118,7 +118,7 @@ func (s *MembershipService) AddMember(
workspaceID,
)
if existingMembership != nil {
return nil, errors.New("user is already a member of this workspace")
return nil, workspaces_errors.ErrUserAlreadyMember
}
membership := &workspaces_models.WorkspaceMembership{
@@ -153,7 +153,7 @@ func (s *MembershipService) ChangeMemberRole(
}
if memberUserID == changedBy.ID {
return errors.New("cannot change your own role")
return workspaces_errors.ErrCannotChangeOwnRole
}
existingMembership, err := s.membershipRepository.GetMembershipByUserAndWorkspace(
@@ -161,16 +161,16 @@ func (s *MembershipService) ChangeMemberRole(
workspaceID,
)
if err != nil {
return errors.New("user is not a member of this workspace")
return workspaces_errors.ErrUserNotMemberOfWorkspace
}
if existingMembership.Role == users_enums.WorkspaceRoleOwner {
return errors.New("cannot change owner role")
return workspaces_errors.ErrCannotChangeOwnerRole
}
targetUser, err := s.userService.GetUserByID(memberUserID)
if err != nil {
return errors.New("user not found")
return workspaces_errors.ErrUserNotFound
}
if err := s.membershipRepository.UpdateMemberRole(memberUserID, workspaceID, request.Role); err != nil {
@@ -202,7 +202,7 @@ func (s *MembershipService) RemoveMember(
}
if !canManage {
return errors.New("insufficient permissions to remove members")
return workspaces_errors.ErrInsufficientPermissionsToRemoveMembers
}
existingMembership, err := s.membershipRepository.GetMembershipByUserAndWorkspace(
@@ -210,11 +210,11 @@ func (s *MembershipService) RemoveMember(
workspaceID,
)
if err != nil {
return errors.New("user is not a member of this workspace")
return workspaces_errors.ErrUserNotMemberOfWorkspace
}
if existingMembership.Role == users_enums.WorkspaceRoleOwner {
return errors.New("cannot remove workspace owner, transfer ownership first")
return workspaces_errors.ErrCannotRemoveWorkspaceOwner
}
if existingMembership.Role == users_enums.WorkspaceRoleAdmin {
@@ -223,13 +223,13 @@ func (s *MembershipService) RemoveMember(
return err
}
if !canManageAdmins {
return errors.New("only workspace owner can remove admins")
return workspaces_errors.ErrOnlyOwnerCanRemoveAdmins
}
}
targetUser, err := s.userService.GetUserByID(memberUserID)
if err != nil {
return errors.New("user not found")
return workspaces_errors.ErrUserNotFound
}
if err := s.membershipRepository.RemoveMember(memberUserID, workspaceID); err != nil {
@@ -257,21 +257,21 @@ func (s *MembershipService) TransferOwnership(
if user.Role != users_enums.UserRoleAdmin &&
(currentRole == nil || *currentRole != users_enums.WorkspaceRoleOwner) {
return errors.New("only workspace owner or admin can transfer ownership")
return workspaces_errors.ErrOnlyOwnerOrAdminCanTransferOwnership
}
newOwner, err := s.userService.GetUserByEmail(request.NewOwnerEmail)
if err != nil {
return errors.New("new owner not found")
return workspaces_errors.ErrNewOwnerNotFound
}
if newOwner == nil {
return errors.New("new owner not found")
return workspaces_errors.ErrNewOwnerNotFound
}
_, err = s.membershipRepository.GetMembershipByUserAndWorkspace(newOwner.ID, workspaceID)
if err != nil {
return errors.New("new owner must be a workspace member")
return workspaces_errors.ErrNewOwnerMustBeMember
}
currentOwner, err := s.membershipRepository.GetWorkspaceOwner(workspaceID)
@@ -280,7 +280,7 @@ func (s *MembershipService) TransferOwnership(
}
if currentOwner == nil {
return errors.New("no current workspace owner found")
return workspaces_errors.ErrNoCurrentWorkspaceOwner
}
if err := s.membershipRepository.UpdateMemberRole(newOwner.ID, workspaceID, users_enums.WorkspaceRoleOwner); err != nil {
@@ -311,7 +311,7 @@ func (s *MembershipService) validateCanManageMembership(
return err
}
if !canManageAdmins {
return errors.New("only workspace owner can add/manage admins")
return workspaces_errors.ErrOnlyOwnerCanAddManageAdmins
}
return nil
}
@@ -322,7 +322,7 @@ func (s *MembershipService) validateCanManageMembership(
}
if !canManageMembership {
return errors.New("insufficient permissions to manage members")
return workspaces_errors.ErrInsufficientPermissionsToManageMembers
}
return nil

View File

@@ -1,7 +1,6 @@
package workspaces_services
import (
"errors"
"fmt"
"time"
@@ -10,6 +9,7 @@ import (
users_models "databasus-backend/internal/features/users/models"
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"
@@ -43,7 +43,7 @@ func (s *WorkspaceService) CreateWorkspace(
}
if !creator.CanCreateWorkspaces(settings) {
return nil, errors.New("insufficient permissions to create workspaces")
return nil, workspaces_errors.ErrInsufficientPermissionsToCreateWorkspaces
}
workspace := &workspaces_models.Workspace{
@@ -91,7 +91,7 @@ func (s *WorkspaceService) GetWorkspace(
return nil, err
}
if !canView {
return nil, errors.New("insufficient permissions to view workspace")
return nil, workspaces_errors.ErrInsufficientPermissionsToViewWorkspace
}
return s.workspaceRepository.GetWorkspaceByID(workspaceID)
@@ -121,7 +121,7 @@ func (s *WorkspaceService) UpdateWorkspace(
return nil, err
}
if !canManage {
return nil, errors.New("insufficient permissions to update workspace")
return nil, workspaces_errors.ErrInsufficientPermissionsToUpdateWorkspace
}
existingWorkspace, err := s.workspaceRepository.GetWorkspaceByID(workspaceID)
@@ -155,7 +155,7 @@ func (s *WorkspaceService) DeleteWorkspace(workspaceID uuid.UUID, user *users_mo
}
if userWorkspaceRole == nil || *userWorkspaceRole != users_enums.WorkspaceRoleOwner {
return errors.New("only workspace owner or admin can delete workspace")
return workspaces_errors.ErrOnlyOwnerOrAdminCanDeleteWorkspace
}
}
@@ -299,7 +299,7 @@ func (s *WorkspaceService) GetWorkspaceAuditLogs(
return nil, err
}
if !canView {
return nil, errors.New("insufficient permissions to view workspace audit logs")
return nil, workspaces_errors.ErrInsufficientPermissionsToViewWorkspaceAuditLogs
}
return s.auditLogService.GetWorkspaceAuditLogs(workspaceID, request)

View File

@@ -2,6 +2,7 @@ import { getApplicationServer } from '../../../constants';
import RequestOptions from '../../../shared/api/RequestOptions';
import { apiHelper } from '../../../shared/api/apiHelper';
import type { BackupConfig } from '../model/BackupConfig';
import type { TransferDatabaseRequest } from '../model/TransferDatabaseRequest';
export const backupConfigApi = {
async saveBackupConfig(config: BackupConfig) {
@@ -32,4 +33,25 @@ export const backupConfigApi = {
)
.then((res) => res.isUsing);
},
async getDatabasesCountForStorage(storageId: string): Promise<number> {
return await apiHelper
.fetchGetJson<{
count: number;
}>(
`${getApplicationServer()}/api/v1/backup-configs/storage/${storageId}/databases-count`,
undefined,
true,
)
.then((res) => res.count);
},
async transferDatabase(databaseId: string, request: TransferDatabaseRequest): Promise<void> {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(request));
await apiHelper.fetchPostJson(
`${getApplicationServer()}/api/v1/backup-configs/database/${databaseId}/transfer`,
requestOptions,
);
},
};

View File

@@ -5,3 +5,4 @@ export type { Backup } from './model/Backup';
export type { BackupConfig } from './model/BackupConfig';
export { BackupNotificationType } from './model/BackupNotificationType';
export { BackupEncryption } from './model/BackupEncryption';
export type { TransferDatabaseRequest } from './model/TransferDatabaseRequest';

View File

@@ -0,0 +1,7 @@
export interface TransferDatabaseRequest {
targetWorkspaceId: string;
targetStorageId?: string;
isTransferWithStorage: boolean;
isTransferWithNotifiers: boolean;
targetNotifierIds: string[];
}

View File

@@ -88,6 +88,19 @@ export const databaseApi = {
.then((res) => res.isUsing);
},
async getDatabasesCountForNotifier(notifierId: string): Promise<number> {
const requestOptions: RequestOptions = new RequestOptions();
return apiHelper
.fetchGetJson<{
count: number;
}>(
`${getApplicationServer()}/api/v1/databases/notifier/${notifierId}/databases-count`,
requestOptions,
true,
)
.then((res) => res.count);
},
async isUserReadOnly(database: Database) {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(database));

View File

@@ -55,4 +55,13 @@ export const notifierApi = {
requestOptions,
);
},
async transferNotifier(notifierId: string, targetWorkspaceId: string) {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify({ targetWorkspaceId }));
return apiHelper.fetchPostJson(
`${getApplicationServer()}/api/v1/notifiers/${notifierId}/transfer`,
requestOptions,
);
},
};

View File

@@ -55,4 +55,13 @@ export const storageApi = {
requestOptions,
);
},
async transferStorage(storageId: string, targetWorkspaceId: string) {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify({ targetWorkspaceId }));
return apiHelper.fetchPostJson(
`${getApplicationServer()}/api/v1/storages/${storageId}/transfer`,
requestOptions,
);
},
};

View File

@@ -76,6 +76,7 @@ export const DatabaseComponent = ({
isCanManageDBs={isCanManageDBs}
/>
)}
{currentTab === 'backups' && (
<>
<HealthckeckAttemptsComponent database={database} />

View File

@@ -1,12 +1,20 @@
import { CloseOutlined, InfoCircleOutlined } from '@ant-design/icons';
import {
ArrowRightOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { Button, Input } from 'antd';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { backupConfigApi } from '../../../entity/backups';
import { type Database, databaseApi } from '../../../entity/databases';
import { ToastHelper } from '../../../shared/toast';
import { ConfirmationComponent } from '../../../shared/ui';
import { EditBackupConfigComponent, ShowBackupConfigComponent } from '../../backups';
import { EditHealthcheckConfigComponent, ShowHealthcheckConfigComponent } from '../../healthcheck';
import { DatabaseTransferDialogComponent } from './DatabaseTransferDialogComponent';
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
import { ShowDatabaseNotifiersComponent } from './show/ShowDatabaseNotifiersComponent';
@@ -46,6 +54,15 @@ export const DatabaseConfigComponent = ({
const [isCopying, setIsCopying] = useState(false);
const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [isShowCopyConfirm, setIsShowCopyConfirm] = useState(false);
const [isShowTransferDialog, setIsShowTransferDialog] = useState(false);
const [currentStorageId, setCurrentStorageId] = useState<string | undefined>();
useEffect(() => {
backupConfigApi.getBackupConfigByDbID(database.id).then((config) => {
setCurrentStorageId(config.storage?.id);
});
}, [database.id]);
const loadSettings = () => {
setDatabase(undefined);
@@ -57,6 +74,7 @@ export const DatabaseConfigComponent = ({
if (!database) return;
setIsCopying(true);
setIsShowCopyConfirm(false);
databaseApi
.copyDatabase(database.id)
@@ -377,30 +395,50 @@ export const DatabaseConfigComponent = ({
Test connection
</Button>
<Button
type="primary"
className="w-full sm:mr-1 sm:w-auto"
onClick={copyDatabase}
loading={isCopying}
disabled={isCopying}
>
Copy
</Button>
{isCanManageDBs && (
<>
<Button
type="primary"
ghost
icon={<ArrowRightOutlined />}
onClick={() => setIsShowTransferDialog(true)}
className="sm:mr-1"
/>
<Button
type="primary"
className="w-full sm:w-auto"
danger
onClick={() => setIsShowRemoveConfirm(true)}
ghost
loading={isRemoving}
disabled={isRemoving}
>
Remove
</Button>
<Button
type="primary"
ghost
icon={<CopyOutlined />}
onClick={() => setIsShowCopyConfirm(true)}
loading={isCopying}
disabled={isCopying}
className="sm:mr-1"
/>
<Button
type="primary"
ghost
danger
icon={<DeleteOutlined />}
onClick={() => setIsShowRemoveConfirm(true)}
loading={isRemoving}
disabled={isRemoving}
/>
</>
)}
</div>
)}
{isShowCopyConfirm && (
<ConfirmationComponent
onConfirm={copyDatabase}
onDecline={() => setIsShowCopyConfirm(false)}
description="Are you sure you want to copy this database? A new database with the same settings will be created."
actionText="Copy"
actionButtonColor="blue"
/>
)}
{isShowRemoveConfirm && (
<ConfirmationComponent
onConfirm={remove}
@@ -410,6 +448,18 @@ export const DatabaseConfigComponent = ({
actionButtonColor="red"
/>
)}
{isShowTransferDialog && (
<DatabaseTransferDialogComponent
database={database}
currentStorageId={currentStorageId}
onClose={() => setIsShowTransferDialog(false)}
onTransferred={() => {
setIsShowTransferDialog(false);
window.location.reload();
}}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,465 @@
import { CheckCircleOutlined, ExclamationCircleOutlined, SwapOutlined } from '@ant-design/icons';
import { Button, Modal, Radio, Select, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { backupConfigApi } from '../../../entity/backups';
import type { TransferDatabaseRequest } from '../../../entity/backups';
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 WorkspaceResponse, workspaceApi } from '../../../entity/workspaces';
import { ToastHelper } from '../../../shared/toast';
import { EditNotifierComponent } from '../../notifiers/ui/edit/EditNotifierComponent';
import { EditStorageComponent } from '../../storages/ui/edit/EditStorageComponent';
interface Props {
database: Database;
currentStorageId?: string;
onClose: () => void;
onTransferred: () => void;
}
interface NotifierUsageInfo {
notifier: Notifier;
databaseCount: number;
canTransfer: boolean;
}
export const DatabaseTransferDialogComponent = ({
database,
currentStorageId,
onClose,
onTransferred,
}: Props) => {
const [isLoading, setIsLoading] = useState(true);
const [workspaces, setWorkspaces] = useState<WorkspaceResponse[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | undefined>();
const [storageOption, setStorageOption] = useState<'transfer' | 'select'>('select');
const [storageUsageCount, setStorageUsageCount] = useState<number>(0);
const [isLoadingStorageCount, setIsLoadingStorageCount] = useState(false);
const [targetStorages, setTargetStorages] = useState<Storage[]>([]);
const [selectedStorageId, setSelectedStorageId] = useState<string | undefined>();
const [isShowCreateStorage, setIsShowCreateStorage] = useState(false);
const [storageSelectKey, setStorageSelectKey] = useState(0);
const [notifierOption, setNotifierOption] = useState<'transfer' | 'select'>('select');
const [notifierUsageInfo, setNotifierUsageInfo] = useState<NotifierUsageInfo[]>([]);
const [isLoadingNotifierCounts, setIsLoadingNotifierCounts] = useState(false);
const [targetNotifiers, setTargetNotifiers] = useState<Notifier[]>([]);
const [selectedNotifierIds, setSelectedNotifierIds] = useState<string[]>([]);
const [isShowCreateNotifier, setIsShowCreateNotifier] = useState(false);
const [notifierSelectKey, setNotifierSelectKey] = useState(0);
const [isTransferring, setIsTransferring] = useState(false);
const hasCurrentStorage = !!currentStorageId;
const hasCurrentNotifiers = database.notifiers && database.notifiers.length > 0;
const loadWorkspaces = async () => {
setIsLoading(true);
try {
const response = await workspaceApi.getWorkspaces();
const filteredWorkspaces = response.workspaces.filter((w) => w.id !== database.workspaceId);
setWorkspaces(filteredWorkspaces);
} catch (e) {
alert((e as Error).message);
}
setIsLoading(false);
};
const loadStorageUsageCount = async () => {
if (!currentStorageId) return;
setIsLoadingStorageCount(true);
try {
const count = await backupConfigApi.getDatabasesCountForStorage(currentStorageId);
setStorageUsageCount(count);
} catch (e) {
alert((e as Error).message);
}
setIsLoadingStorageCount(false);
};
const loadNotifierUsageCounts = async () => {
if (!database.notifiers || database.notifiers.length === 0) return;
setIsLoadingNotifierCounts(true);
try {
const usageInfo: NotifierUsageInfo[] = await Promise.all(
database.notifiers.map(async (notifier) => {
const count = await databaseApi.getDatabasesCountForNotifier(notifier.id);
return {
notifier,
databaseCount: count,
canTransfer: count === 1,
};
}),
);
setNotifierUsageInfo(usageInfo);
} catch (e) {
alert((e as Error).message);
}
setIsLoadingNotifierCounts(false);
};
const loadTargetWorkspaceData = async (workspaceId: string) => {
try {
const [storages, notifiers] = await Promise.all([
storageApi.getStorages(workspaceId),
notifierApi.getNotifiers(workspaceId),
]);
setTargetStorages(storages);
setTargetNotifiers(notifiers);
} catch (e) {
alert((e as Error).message);
}
};
const handleTransfer = async () => {
if (!selectedWorkspaceId) return;
const request: TransferDatabaseRequest = {
targetWorkspaceId: selectedWorkspaceId,
isTransferWithStorage: storageOption === 'transfer',
isTransferWithNotifiers: notifierOption === 'transfer',
targetStorageId: storageOption === 'select' ? selectedStorageId : undefined,
targetNotifierIds: notifierOption === 'select' ? selectedNotifierIds : [],
};
setIsTransferring(true);
try {
await backupConfigApi.transferDatabase(database.id, request);
ToastHelper.showToast({
title: 'Database transferred successfully!',
description: `"${database.name}" has been transferred to the new workspace`,
});
onTransferred();
} catch (e) {
alert((e as Error).message);
}
setIsTransferring(false);
};
useEffect(() => {
loadWorkspaces();
loadStorageUsageCount();
loadNotifierUsageCounts();
}, [database.id]);
useEffect(() => {
if (selectedWorkspaceId) {
loadTargetWorkspaceData(selectedWorkspaceId);
setSelectedStorageId(undefined);
setSelectedNotifierIds([]);
}
}, [selectedWorkspaceId]);
const canTransferWithStorage = storageUsageCount <= 1;
const notifiersBlockingTransfer = notifierUsageInfo.filter((info) => !info.canTransfer);
const notifiersCanTransfer = notifierUsageInfo.filter((info) => info.canTransfer);
const isStorageValid =
storageOption === 'transfer'
? canTransferWithStorage && hasCurrentStorage
: !!selectedStorageId;
const isFormValid = !!selectedWorkspaceId && isStorageValid;
return (
<Modal
title={
<div className="flex items-center gap-2">
<SwapOutlined />
Transfer database to another workspace
</div>
}
footer={null}
open={true}
onCancel={onClose}
maskClosable={false}
width={550}
>
{isLoading ? (
<div className="flex justify-center py-5">
<Spin />
</div>
) : (
<div className="py-3">
{/* Workspace Selection */}
<div className="mb-5">
<div className="mb-2 font-medium">Target workspace</div>
<Select
value={selectedWorkspaceId}
onChange={setSelectedWorkspaceId}
className="w-full"
placeholder="Select workspace"
options={workspaces.map((w) => ({ label: w.name, value: w.id }))}
/>
</div>
{selectedWorkspaceId && (
<>
{/* Storage Transfer Options */}
<div className="mb-5">
<div className="mb-2 font-medium">Storage</div>
<Radio.Group
value={storageOption}
onChange={(e) => setStorageOption(e.target.value)}
className="flex flex-col gap-3"
>
{hasCurrentStorage && (
<div>
<Radio value="transfer">
<span className="flex items-center gap-2">
Transfer with existing storage
{isLoadingStorageCount && <Spin size="small" />}
</span>
</Radio>
</div>
)}
<div>
<Radio value="select">Select storage from target workspace</Radio>
</div>
</Radio.Group>
{storageOption === 'transfer' && hasCurrentStorage && (
<div className="mt-2 ml-6">
{isLoadingStorageCount ? (
<Spin size="small" />
) : !canTransferWithStorage ? (
<div className="flex items-center gap-2 rounded border border-red-300 bg-red-50 p-2 text-sm text-red-600 dark:border-red-600 dark:bg-red-900/20">
<ExclamationCircleOutlined />
<span>
This storage is used by {storageUsageCount} databases. Transfer is blocked
because other databases depend on it.
</span>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircleOutlined />
Storage can be transferred
</div>
)}
</div>
)}
{storageOption === 'select' && (
<div className="mt-2 ml-6">
<div className="flex items-center gap-2">
<Select
key={storageSelectKey}
value={selectedStorageId}
onChange={(storageId) => {
if (storageId === 'create-new-storage') {
setIsShowCreateStorage(true);
return;
}
setSelectedStorageId(storageId);
}}
className="w-full max-w-[300px]"
placeholder="Select storage"
options={[
...targetStorages.map((s) => ({ label: s.name, value: s.id })),
{ label: 'Create new storage', value: 'create-new-storage' },
]}
/>
{selectedStorageId &&
targetStorages.find((s) => s.id === selectedStorageId)?.type && (
<img
src={getStorageLogoFromType(
targetStorages.find((s) => s.id === selectedStorageId)!.type,
)}
alt="storageIcon"
className="h-4 w-4"
/>
)}
</div>
</div>
)}
</div>
{/* Notifier transfer options */}
{hasCurrentNotifiers && (
<div className="mb-5">
<div className="mb-2 font-medium">Notifiers (optional)</div>
<Radio.Group
value={notifierOption}
onChange={(e) => setNotifierOption(e.target.value)}
className="flex flex-col gap-3"
>
<div>
<Radio value="transfer">Transfer notifiers with database</Radio>
</div>
<div>
<Radio value="select">Select notifiers from target workspace</Radio>
</div>
</Radio.Group>
{notifierOption === 'transfer' && (
<div className="mt-2 ml-6">
{isLoadingNotifierCounts ? (
<Spin size="small" />
) : (
<div className="space-y-1">
{notifiersCanTransfer.length > 0 && (
<div className="text-sm text-green-600 dark:text-green-400">
<div className="mb-1 flex items-center gap-1">
<CheckCircleOutlined />
<span>Will be transferred:</span>
</div>
<ul className="ml-5 list-disc">
{notifiersCanTransfer.map((info) => (
<li key={info.notifier.id}>{info.notifier.name}</li>
))}
</ul>
</div>
)}
{notifiersBlockingTransfer.length > 0 && (
<div className="mt-2 rounded border border-red-300 bg-red-50 p-2 text-sm text-red-600 dark:border-red-600 dark:bg-red-900/20 dark:text-red-600">
<div className="mb-1 flex items-center gap-1">
<ExclamationCircleOutlined />
<span>Will NOT be transferred (used by other databases):</span>
</div>
<ul className="ml-5 list-disc">
{notifiersBlockingTransfer.map((info) => (
<li key={info.notifier.id}>
{info.notifier.name} (used by {info.databaseCount} databases)
</li>
))}
</ul>
</div>
)}
{notifiersCanTransfer.length === 0 &&
notifiersBlockingTransfer.length > 0 && (
<div className="mt-1 text-sm text-gray-500 dark:text-gray-400">
No notifiers will be transferred. You can select notifiers from the
target workspace after transfer.
</div>
)}
</div>
)}
</div>
)}
{notifierOption === 'select' && (
<div className="mt-2 ml-6">
<Select
key={notifierSelectKey}
mode="multiple"
value={selectedNotifierIds}
onChange={(notifierIds) => {
if (notifierIds.includes('create-new-notifier')) {
setIsShowCreateNotifier(true);
return;
}
setSelectedNotifierIds(notifierIds);
}}
className="w-full max-w-[300px]"
placeholder="Select notifiers (optional)"
options={[
...targetNotifiers.map((n) => ({ label: n.name, value: n.id })),
{ label: 'Create new notifier', value: 'create-new-notifier' },
]}
/>
</div>
)}
</div>
)}
</>
)}
{/* Action Buttons */}
<div className="mt-5 flex gap-2">
<Button type="default" onClick={onClose}>
Cancel
</Button>
<Button
type="primary"
onClick={handleTransfer}
loading={isTransferring}
disabled={!isFormValid || isTransferring}
>
Transfer
</Button>
</div>
</div>
)}
{/* Create Storage Modal */}
{isShowCreateStorage && selectedWorkspaceId && (
<Modal
title="Add storage"
footer={null}
open={isShowCreateStorage}
onCancel={() => {
setIsShowCreateStorage(false);
setStorageSelectKey((prev) => prev + 1);
}}
maskClosable={false}
>
<div className="my-3 max-w-[275px] text-gray-500 dark:text-gray-400">
Storage - is a place where backups will be stored (local disk, S3, Google Drive, etc.)
</div>
<EditStorageComponent
workspaceId={selectedWorkspaceId}
isShowName
isShowClose={false}
onClose={() => setIsShowCreateStorage(false)}
onChanged={(storage) => {
loadTargetWorkspaceData(selectedWorkspaceId);
setSelectedStorageId(storage.id);
setIsShowCreateStorage(false);
}}
/>
</Modal>
)}
{/* Create Notifier Modal */}
{isShowCreateNotifier && selectedWorkspaceId && (
<Modal
title="Add notifier"
footer={null}
open={isShowCreateNotifier}
onCancel={() => {
setIsShowCreateNotifier(false);
setNotifierSelectKey((prev) => prev + 1);
}}
maskClosable={false}
>
<div className="my-3 max-w-[275px] text-gray-500 dark:text-gray-400">
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
</div>
<EditNotifierComponent
workspaceId={selectedWorkspaceId}
isShowName
isShowClose={false}
onClose={() => setIsShowCreateNotifier(false)}
onChanged={(notifier) => {
loadTargetWorkspaceData(selectedWorkspaceId);
setSelectedNotifierIds([...selectedNotifierIds, notifier.id]);
setIsShowCreateNotifier(false);
}}
/>
</Modal>
)}
</Modal>
);
};

View File

@@ -86,12 +86,20 @@ export const EditMariaDbSpecificDataComponent = ({
};
const testConnection = async () => {
if (!editingDatabase) return;
if (!editingDatabase?.mariadb) return;
setIsTestingConnection(true);
setIsConnectionFailed(false);
const trimmedDatabase = {
...editingDatabase,
mariadb: {
...editingDatabase.mariadb,
password: editingDatabase.mariadb.password?.trim(),
},
};
try {
await databaseApi.testDatabaseConnectionDirect(editingDatabase);
await databaseApi.testDatabaseConnectionDirect(trimmedDatabase);
setIsConnectionTested(true);
ToastHelper.showToast({
title: 'Connection test passed',
@@ -106,13 +114,21 @@ export const EditMariaDbSpecificDataComponent = ({
};
const saveDatabase = async () => {
if (!editingDatabase) return;
if (!editingDatabase?.mariadb) return;
const trimmedDatabase = {
...editingDatabase,
mariadb: {
...editingDatabase.mariadb,
password: editingDatabase.mariadb.password?.trim(),
},
};
if (isSaveToApi) {
setIsSaving(true);
try {
await databaseApi.updateDatabase(editingDatabase);
await databaseApi.updateDatabase(trimmedDatabase);
} catch (e) {
alert((e as Error).message);
}
@@ -120,7 +136,7 @@ export const EditMariaDbSpecificDataComponent = ({
setIsSaving(false);
}
onSaved(editingDatabase);
onSaved(trimmedDatabase);
};
useEffect(() => {
@@ -246,7 +262,7 @@ export const EditMariaDbSpecificDataComponent = ({
setEditingDatabase({
...editingDatabase,
mariadb: { ...editingDatabase.mariadb, password: e.target.value.trim() },
mariadb: { ...editingDatabase.mariadb, password: e.target.value },
});
setIsConnectionTested(false);
}}

View File

@@ -91,12 +91,20 @@ export const EditMongoDbSpecificDataComponent = ({
};
const testConnection = async () => {
if (!editingDatabase) return;
if (!editingDatabase?.mongodb) return;
setIsTestingConnection(true);
setIsConnectionFailed(false);
const trimmedDatabase = {
...editingDatabase,
mongodb: {
...editingDatabase.mongodb,
password: editingDatabase.mongodb.password?.trim(),
},
};
try {
await databaseApi.testDatabaseConnectionDirect(editingDatabase);
await databaseApi.testDatabaseConnectionDirect(trimmedDatabase);
setIsConnectionTested(true);
ToastHelper.showToast({
title: 'Connection test passed',
@@ -111,13 +119,21 @@ export const EditMongoDbSpecificDataComponent = ({
};
const saveDatabase = async () => {
if (!editingDatabase) return;
if (!editingDatabase?.mongodb) return;
const trimmedDatabase = {
...editingDatabase,
mongodb: {
...editingDatabase.mongodb,
password: editingDatabase.mongodb.password?.trim(),
},
};
if (isSaveToApi) {
setIsSaving(true);
try {
await databaseApi.updateDatabase(editingDatabase);
await databaseApi.updateDatabase(trimmedDatabase);
} catch (e) {
alert((e as Error).message);
}
@@ -125,7 +141,7 @@ export const EditMongoDbSpecificDataComponent = ({
setIsSaving(false);
}
onSaved(editingDatabase);
onSaved(trimmedDatabase);
};
useEffect(() => {
@@ -251,7 +267,7 @@ export const EditMongoDbSpecificDataComponent = ({
setEditingDatabase({
...editingDatabase,
mongodb: { ...editingDatabase.mongodb, password: e.target.value.trim() },
mongodb: { ...editingDatabase.mongodb, password: e.target.value },
});
setIsConnectionTested(false);
}}

View File

@@ -86,12 +86,20 @@ export const EditMySqlSpecificDataComponent = ({
};
const testConnection = async () => {
if (!editingDatabase) return;
if (!editingDatabase?.mysql) return;
setIsTestingConnection(true);
setIsConnectionFailed(false);
const trimmedDatabase = {
...editingDatabase,
mysql: {
...editingDatabase.mysql,
password: editingDatabase.mysql.password?.trim(),
},
};
try {
await databaseApi.testDatabaseConnectionDirect(editingDatabase);
await databaseApi.testDatabaseConnectionDirect(trimmedDatabase);
setIsConnectionTested(true);
ToastHelper.showToast({
title: 'Connection test passed',
@@ -106,13 +114,21 @@ export const EditMySqlSpecificDataComponent = ({
};
const saveDatabase = async () => {
if (!editingDatabase) return;
if (!editingDatabase?.mysql) return;
const trimmedDatabase = {
...editingDatabase,
mysql: {
...editingDatabase.mysql,
password: editingDatabase.mysql.password?.trim(),
},
};
if (isSaveToApi) {
setIsSaving(true);
try {
await databaseApi.updateDatabase(editingDatabase);
await databaseApi.updateDatabase(trimmedDatabase);
} catch (e) {
alert((e as Error).message);
}
@@ -120,7 +136,7 @@ export const EditMySqlSpecificDataComponent = ({
setIsSaving(false);
}
onSaved(editingDatabase);
onSaved(trimmedDatabase);
};
useEffect(() => {
@@ -246,7 +262,7 @@ export const EditMySqlSpecificDataComponent = ({
setEditingDatabase({
...editingDatabase,
mysql: { ...editingDatabase.mysql, password: e.target.value.trim() },
mysql: { ...editingDatabase.mysql, password: e.target.value },
});
setIsConnectionTested(false);
}}

View File

@@ -120,12 +120,20 @@ export const EditPostgreSqlSpecificDataComponent = ({
};
const testConnection = async () => {
if (!editingDatabase) return;
if (!editingDatabase?.postgresql) return;
setIsTestingConnection(true);
setIsConnectionFailed(false);
const trimmedDatabase = {
...editingDatabase,
postgresql: {
...editingDatabase.postgresql,
password: editingDatabase.postgresql.password?.trim(),
},
};
try {
await databaseApi.testDatabaseConnectionDirect(editingDatabase);
await databaseApi.testDatabaseConnectionDirect(trimmedDatabase);
setIsConnectionTested(true);
ToastHelper.showToast({
title: 'Connection test passed',
@@ -140,13 +148,21 @@ export const EditPostgreSqlSpecificDataComponent = ({
};
const saveDatabase = async () => {
if (!editingDatabase) return;
if (!editingDatabase?.postgresql) return;
const trimmedDatabase = {
...editingDatabase,
postgresql: {
...editingDatabase.postgresql,
password: editingDatabase.postgresql.password?.trim(),
},
};
if (isSaveToApi) {
setIsSaving(true);
try {
await databaseApi.updateDatabase(editingDatabase);
await databaseApi.updateDatabase(trimmedDatabase);
} catch (e) {
alert((e as Error).message);
}
@@ -154,7 +170,7 @@ export const EditPostgreSqlSpecificDataComponent = ({
setIsSaving(false);
}
onSaved(editingDatabase);
onSaved(trimmedDatabase);
};
useEffect(() => {
@@ -304,7 +320,7 @@ export const EditPostgreSqlSpecificDataComponent = ({
setEditingDatabase({
...editingDatabase,
postgresql: { ...editingDatabase.postgresql, password: e.target.value.trim() },
postgresql: { ...editingDatabase.postgresql, password: e.target.value },
});
setIsConnectionTested(false);
}}

View File

@@ -1,4 +1,9 @@
import { CloseOutlined, InfoCircleOutlined } from '@ant-design/icons';
import {
ArrowRightOutlined,
CloseOutlined,
DeleteOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { Button, Input, Spin } from 'antd';
import { useState } from 'react';
import { useEffect } from 'react';
@@ -8,6 +13,7 @@ import { notifierApi } from '../../../entity/notifiers';
import type { Notifier } from '../../../entity/notifiers';
import { ToastHelper } from '../../../shared/toast';
import { ConfirmationComponent } from '../../../shared/ui';
import { NotifierTransferDialogComponent } from './NotifierTransferDialogComponent';
import { EditNotifierComponent } from './edit/EditNotifierComponent';
import { ShowNotifierComponent } from './show/ShowNotifierComponent';
@@ -15,6 +21,7 @@ interface Props {
notifierId: string;
onNotifierChanged: (notifier: Notifier) => void;
onNotifierDeleted: () => void;
onNotifierTransferred: () => void;
isCanManageNotifiers: boolean;
}
@@ -22,6 +29,7 @@ export const NotifierComponent = ({
notifierId,
onNotifierChanged,
onNotifierDeleted,
onNotifierTransferred,
isCanManageNotifiers,
}: Props) => {
const [notifier, setNotifier] = useState<Notifier | undefined>();
@@ -38,6 +46,8 @@ export const NotifierComponent = ({
const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [isShowTransferDialog, setIsShowTransferDialog] = useState(false);
const sendTestNotification = () => {
if (!notifier) return;
@@ -254,16 +264,25 @@ export const NotifierComponent = ({
</Button>
{isCanManageNotifiers && (
<Button
type="primary"
danger
onClick={() => setIsShowRemoveConfirm(true)}
ghost
loading={isRemoving}
disabled={isRemoving}
>
Remove
</Button>
<>
<Button
type="primary"
ghost
icon={<ArrowRightOutlined />}
onClick={() => setIsShowTransferDialog(true)}
className="mr-1"
/>
<Button
type="primary"
ghost
danger
icon={<DeleteOutlined />}
onClick={() => setIsShowRemoveConfirm(true)}
loading={isRemoving}
disabled={isRemoving}
/>
</>
)}
</div>
)}
@@ -280,6 +299,17 @@ export const NotifierComponent = ({
/>
)}
</div>
{isShowTransferDialog && notifier && (
<NotifierTransferDialogComponent
notifier={notifier}
onClose={() => setIsShowTransferDialog(false)}
onTransferred={() => {
setIsShowTransferDialog(false);
onNotifierTransferred();
}}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,120 @@
import { Button, Modal, Select, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { databaseApi } from '../../../entity/databases';
import { type Notifier, notifierApi } from '../../../entity/notifiers';
import { type WorkspaceResponse, workspaceApi } from '../../../entity/workspaces';
interface Props {
notifier: Notifier;
onClose: () => void;
onTransferred: () => void;
}
export const NotifierTransferDialogComponent = ({ notifier, onClose, onTransferred }: Props) => {
const [isLoading, setIsLoading] = useState(true);
const [isNotifierInUse, setIsNotifierInUse] = useState(false);
const [workspaces, setWorkspaces] = useState<WorkspaceResponse[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | undefined>();
const [isTransferring, setIsTransferring] = useState(false);
const loadData = async () => {
setIsLoading(true);
try {
const isUsing = await databaseApi.isNotifierUsing(notifier.id);
setIsNotifierInUse(isUsing);
if (!isUsing) {
const response = await workspaceApi.getWorkspaces();
const filteredWorkspaces = response.workspaces.filter((w) => w.id !== notifier.workspaceId);
setWorkspaces(filteredWorkspaces);
}
} catch (e) {
alert((e as Error).message);
}
setIsLoading(false);
};
const transferNotifier = async () => {
if (!selectedWorkspaceId) return;
setIsTransferring(true);
try {
await notifierApi.transferNotifier(notifier.id, selectedWorkspaceId);
onTransferred();
} catch (e) {
alert((e as Error).message);
}
setIsTransferring(false);
};
useEffect(() => {
loadData();
}, [notifier.id]);
return (
<Modal
title="Transfer notifier to another workspace"
footer={null}
open={true}
onCancel={onClose}
maskClosable={false}
>
{isLoading ? (
<div className="flex justify-center py-5">
<Spin />
</div>
) : isNotifierInUse ? (
<div className="py-3">
<div className="text-gray-700 dark:text-gray-300">
This notifier is used by some databases. Please transfer or remove related databases
first.
</div>
<div className="mt-5">
<Button type="primary" onClick={onClose}>
OK
</Button>
</div>
</div>
) : (
<div className="py-3">
<div className="mb-3 text-gray-500 dark:text-gray-400">
Select a workspace to transfer this notifier to.
</div>
<div className="mb-5 flex items-center">
<div className="min-w-[120px]">Target workspace</div>
<Select
value={selectedWorkspaceId}
onChange={setSelectedWorkspaceId}
className="min-w-[200px] grow"
placeholder="Select workspace"
options={workspaces.map((w) => ({ label: w.name, value: w.id }))}
/>
</div>
<div className="flex gap-2">
<Button type="default" onClick={onClose}>
Cancel
</Button>
<Button
type="primary"
onClick={transferNotifier}
loading={isTransferring}
disabled={!selectedWorkspaceId || isTransferring}
>
Transfer
</Button>
</div>
</div>
)}
</Modal>
);
};

View File

@@ -166,6 +166,13 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
updateSelectedNotifierId(remainingNotifiers[0]?.id);
loadNotifiers();
}}
onNotifierTransferred={() => {
const remainingNotifiers = notifiers.filter(
(notifier) => notifier.id !== selectedNotifierId,
);
updateSelectedNotifierId(remainingNotifiers[0]?.id);
loadNotifiers();
}}
isCanManageNotifiers={isCanManageNotifiers}
/>
</div>

View File

@@ -163,7 +163,7 @@ export const RestoresComponent = ({ database, backup }: Props) => {
loading={isRestoreInProgress}
onClick={() => setIsShowRestore(true)}
>
Restore from backup
Select database to restore to
</Button>
{restores.length === 0 && (

View File

@@ -1,4 +1,9 @@
import { CloseOutlined, InfoCircleOutlined } from '@ant-design/icons';
import {
ArrowRightOutlined,
CloseOutlined,
DeleteOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { Button, Input, Spin } from 'antd';
import { useState } from 'react';
import { useEffect } from 'react';
@@ -8,6 +13,7 @@ import { storageApi } from '../../../entity/storages';
import type { Storage } from '../../../entity/storages';
import { ToastHelper } from '../../../shared/toast';
import { ConfirmationComponent } from '../../../shared/ui';
import { StorageTransferDialogComponent } from './StorageTransferDialogComponent';
import { EditStorageComponent } from './edit/EditStorageComponent';
import { ShowStorageComponent } from './show/ShowStorageComponent';
@@ -15,6 +21,7 @@ interface Props {
storageId: string;
onStorageChanged: (storage: Storage) => void;
onStorageDeleted: () => void;
onStorageTransferred: () => void;
isCanManageStorages: boolean;
}
@@ -22,6 +29,7 @@ export const StorageComponent = ({
storageId,
onStorageChanged,
onStorageDeleted,
onStorageTransferred,
isCanManageStorages,
}: Props) => {
const [storage, setStorage] = useState<Storage | undefined>();
@@ -38,6 +46,8 @@ export const StorageComponent = ({
const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [isShowTransferDialog, setIsShowTransferDialog] = useState(false);
const testConnection = () => {
if (!storage) return;
@@ -250,16 +260,25 @@ export const StorageComponent = ({
</Button>
{isCanManageStorages && (
<Button
type="primary"
danger
onClick={() => setIsShowRemoveConfirm(true)}
ghost
loading={isRemoving}
disabled={isRemoving}
>
Remove
</Button>
<>
<Button
type="primary"
ghost
icon={<ArrowRightOutlined />}
onClick={() => setIsShowTransferDialog(true)}
className="mr-1"
/>
<Button
type="primary"
ghost
danger
icon={<DeleteOutlined />}
onClick={() => setIsShowRemoveConfirm(true)}
loading={isRemoving}
disabled={isRemoving}
/>
</>
)}
</div>
)}
@@ -276,6 +295,17 @@ export const StorageComponent = ({
/>
)}
</div>
{isShowTransferDialog && storage && (
<StorageTransferDialogComponent
storage={storage}
onClose={() => setIsShowTransferDialog(false)}
onTransferred={() => {
setIsShowTransferDialog(false);
onStorageTransferred();
}}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,120 @@
import { Button, Modal, Select, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { backupConfigApi } from '../../../entity/backups';
import { type Storage, storageApi } from '../../../entity/storages';
import { type WorkspaceResponse, workspaceApi } from '../../../entity/workspaces';
interface Props {
storage: Storage;
onClose: () => void;
onTransferred: () => void;
}
export const StorageTransferDialogComponent = ({ storage, onClose, onTransferred }: Props) => {
const [isLoading, setIsLoading] = useState(true);
const [isStorageInUse, setIsStorageInUse] = useState(false);
const [workspaces, setWorkspaces] = useState<WorkspaceResponse[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | undefined>();
const [isTransferring, setIsTransferring] = useState(false);
const loadData = async () => {
setIsLoading(true);
try {
const isUsing = await backupConfigApi.isStorageUsing(storage.id);
setIsStorageInUse(isUsing);
if (!isUsing) {
const response = await workspaceApi.getWorkspaces();
const filteredWorkspaces = response.workspaces.filter((w) => w.id !== storage.workspaceId);
setWorkspaces(filteredWorkspaces);
}
} catch (e) {
alert((e as Error).message);
}
setIsLoading(false);
};
const transferStorage = async () => {
if (!selectedWorkspaceId) return;
setIsTransferring(true);
try {
await storageApi.transferStorage(storage.id, selectedWorkspaceId);
onTransferred();
} catch (e) {
alert((e as Error).message);
}
setIsTransferring(false);
};
useEffect(() => {
loadData();
}, [storage.id]);
return (
<Modal
title="Transfer storage to another workspace"
footer={null}
open={true}
onCancel={onClose}
maskClosable={false}
>
{isLoading ? (
<div className="flex justify-center py-5">
<Spin />
</div>
) : isStorageInUse ? (
<div className="py-3">
<div className="text-gray-700 dark:text-gray-300">
This storage is used by some databases. Please transfer or remove related databases
first.
</div>
<div className="mt-5">
<Button type="primary" onClick={onClose}>
OK
</Button>
</div>
</div>
) : (
<div className="py-3">
<div className="mb-3 text-gray-500 dark:text-gray-400">
Select a workspace to transfer this storage to.
</div>
<div className="mb-5 flex items-center">
<div className="min-w-[120px]">Target workspace</div>
<Select
value={selectedWorkspaceId}
onChange={setSelectedWorkspaceId}
className="min-w-[200px] grow"
placeholder="Select workspace"
options={workspaces.map((w) => ({ label: w.name, value: w.id }))}
/>
</div>
<div className="flex gap-2">
<Button type="default" onClick={onClose}>
Cancel
</Button>
<Button
type="primary"
onClick={transferStorage}
loading={isTransferring}
disabled={!selectedWorkspaceId || isTransferring}
>
Transfer
</Button>
</div>
</div>
)}
</Modal>
);
};

View File

@@ -136,6 +136,13 @@ export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorage
updateSelectedStorageId(remainingStorages[0]?.id);
loadStorages();
}}
onStorageTransferred={() => {
const remainingStorages = storages.filter(
(storage) => storage.id !== selectedStorageId,
);
updateSelectedStorageId(remainingStorages[0]?.id);
loadStorages();
}}
isCanManageStorages={isCanManageStorages}
/>
</div>