mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ba4a7c5a | ||
|
|
52c0f53608 | ||
|
|
a5095acad4 | ||
|
|
a6d32b5c09 | ||
|
|
722560e824 | ||
|
|
496ac6120c | ||
|
|
756c6c87af | ||
|
|
a23d05b735 | ||
|
|
33a8d302eb | ||
|
|
25ed1ffd2a |
@@ -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"
|
||||
|
||||
@@ -217,6 +217,7 @@ func setUpDependencies() {
|
||||
audit_logs.SetupDependencies()
|
||||
notifiers.SetupDependencies()
|
||||
storages.SetupDependencies()
|
||||
backups_config.SetupDependencies()
|
||||
}
|
||||
|
||||
func runBackgroundTasks(log *slog.Logger) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
12
backend/internal/features/audit_logs/errors.go
Normal file
12
backend/internal/features/audit_logs/errors.go
Normal 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",
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
11
backend/internal/features/backups/config/dto.go
Normal file
11
backend/internal/features/backups/config/dto.go
Normal 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"`
|
||||
}
|
||||
30
backend/internal/features/backups/config/errors.go
Normal file
30
backend/internal/features/backups/config/errors.go
Normal 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",
|
||||
)
|
||||
)
|
||||
166
backend/internal/features/backups/config/notifiers_test.go
Normal file
166
backend/internal/features/backups/config/notifiers_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
229
backend/internal/features/backups/config/storages_test.go
Normal file
229
backend/internal/features/backups/config/storages_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -39,4 +39,5 @@ func GetDatabaseController() *DatabaseController {
|
||||
|
||||
func SetupDependencies() {
|
||||
workspaces_services.GetWorkspaceService().AddWorkspaceDeletionListener(databaseService)
|
||||
notifiers.GetNotifierService().SetNotifierDatabaseCounter(databaseService)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
¬ifiers,
|
||||
)
|
||||
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, ¬ifiers,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ var notifierService = &NotifierService{
|
||||
workspaces_services.GetWorkspaceService(),
|
||||
audit_logs.GetAuditLogService(),
|
||||
encryption.GetFieldEncryptor(),
|
||||
nil,
|
||||
}
|
||||
var notifierController = &NotifierController{
|
||||
notifierService,
|
||||
|
||||
7
backend/internal/features/notifiers/dto.go
Normal file
7
backend/internal/features/notifiers/dto.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package notifiers
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type TransferNotifierRequest struct {
|
||||
TargetWorkspaceID uuid.UUID `json:"targetWorkspaceId" binding:"required"`
|
||||
}
|
||||
36
backend/internal/features/notifiers/errors.go
Normal file
36
backend/internal/features/notifiers/errors.go
Normal 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",
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ var storageService = &StorageService{
|
||||
workspaces_services.GetWorkspaceService(),
|
||||
audit_logs.GetAuditLogService(),
|
||||
encryption.GetFieldEncryptor(),
|
||||
nil,
|
||||
}
|
||||
var storageController = &StorageController{
|
||||
storageService,
|
||||
|
||||
7
backend/internal/features/storages/dto.go
Normal file
7
backend/internal/features/storages/dto.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package storages
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type TransferStorageRequest struct {
|
||||
TargetWorkspaceID uuid.UUID `json:"targetWorkspaceId" binding:"required"`
|
||||
}
|
||||
36
backend/internal/features/storages/errors.go
Normal file
36
backend/internal/features/storages/errors.go
Normal 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",
|
||||
)
|
||||
)
|
||||
@@ -30,3 +30,7 @@ type StorageFileSaver interface {
|
||||
|
||||
EncryptSensitiveData(encryptor encryption.FieldEncryptor) error
|
||||
}
|
||||
|
||||
type StorageDatabaseCounter interface {
|
||||
GetStorageAttachedDatabasesIDs(storageID uuid.UUID) ([]uuid.UUID, error)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
7
backend/internal/features/users/errors/errors.go
Normal file
7
backend/internal/features/users/errors/errors.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package users_errors
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInsufficientPermissionsToInviteUsers = errors.New("insufficient permissions to invite users")
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
56
backend/internal/features/workspaces/errors/errors.go
Normal file
56
backend/internal/features/workspaces/errors/errors.go
Normal 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")
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface TransferDatabaseRequest {
|
||||
targetWorkspaceId: string;
|
||||
targetStorageId?: string;
|
||||
isTransferWithStorage: boolean;
|
||||
isTransferWithNotifiers: boolean;
|
||||
targetNotifierIds: string[];
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -76,6 +76,7 @@ export const DatabaseComponent = ({
|
||||
isCanManageDBs={isCanManageDBs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentTab === 'backups' && (
|
||||
<>
|
||||
<HealthckeckAttemptsComponent database={database} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user