From 431e9861f4e024b19e9d63726a60b756601516db Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Sun, 2 Nov 2025 15:42:03 +0300 Subject: [PATCH] FEATURE (workspaces): Add workspaces with users management and global Postgresus settings --- .github/workflows/ci-release.yml | 3 + LICENSE | 2 +- README.md | 4 +- backend/.cursor/rules/controllers-rule.mdc | 132 +- backend/.cursor/rules/crud.mdc | 76 +- backend/.env.development.example | 5 +- backend/.gitignore | 3 +- backend/cmd/main.go | 101 +- backend/internal/config/config.go | 20 + backend/internal/downdetect/controller.go | 37 - backend/internal/downdetect/di.go | 10 - backend/internal/downdetect/service.go | 17 - .../features/audit_logs/controller.go | 111 ++ .../features/audit_logs/controller_test.go | 154 ++ backend/internal/features/audit_logs/di.go | 29 + backend/internal/features/audit_logs/dto.go | 31 + .../internal/features/audit_logs/models.go | 19 + .../features/audit_logs/repository.go | 139 ++ .../internal/features/audit_logs/service.go | 137 ++ .../features/audit_logs/service_test.go | 83 ++ .../backups/background_service_test.go | 74 +- .../features/backups/backups/controller.go | 78 +- .../backups/backups/controller_test.go | 640 ++++++++ .../internal/features/backups/backups/di.go | 6 +- .../features/backups/backups/service.go | 82 +- .../features/backups/backups/service_test.go | 37 +- .../features/backups/backups/testing.go | 20 + .../features/backups/config/controller.go | 64 +- .../backups/config/controller_test.go | 413 ++++++ .../internal/features/backups/config/di.go | 4 +- .../features/backups/config/service.go | 23 +- .../internal/features/databases/controller.go | 190 ++- .../features/databases/controller_test.go | 770 ++++++++++ backend/internal/features/databases/di.go | 13 +- backend/internal/features/databases/model.go | 11 +- .../internal/features/databases/repository.go | 4 +- .../internal/features/databases/service.go | 139 +- .../internal/features/databases/testing.go | 8 +- .../attempt/check_pg_health_uc_test.go | 33 +- .../healthcheck/attempt/controller.go | 11 +- .../healthcheck/attempt/controller_test.go | 261 ++++ .../features/healthcheck/attempt/di.go | 8 +- .../healthcheck/attempt/repository.go | 8 +- .../features/healthcheck/attempt/service.go | 12 +- .../features/healthcheck/config/controller.go | 15 +- .../healthcheck/config/controller_test.go | 328 ++++ .../features/healthcheck/config/di.go | 6 +- .../features/healthcheck/config/service.go | 35 +- .../internal/features/notifiers/controller.go | 139 +- .../features/notifiers/controller_test.go | 514 +++++++ backend/internal/features/notifiers/di.go | 11 +- backend/internal/features/notifiers/model.go | 2 +- .../internal/features/notifiers/repository.go | 4 +- .../internal/features/notifiers/service.go | 106 +- .../internal/features/notifiers/testing.go | 4 +- .../internal/features/restores/controller.go | 39 +- .../features/restores/controller_test.go | 348 +++++ backend/internal/features/restores/di.go | 6 +- backend/internal/features/restores/service.go | 44 +- .../internal/features/storages/controller.go | 141 +- .../features/storages/controller_test.go | 461 ++++-- backend/internal/features/storages/di.go | 11 +- backend/internal/features/storages/model.go | 2 +- .../internal/features/storages/repository.go | 4 +- backend/internal/features/storages/service.go | 106 +- backend/internal/features/storages/testing.go | 4 +- .../tests/postgresql_backup_restore_test.go | 2 +- backend/internal/features/users/controller.go | 98 -- .../internal/features/users/controllers/di.go | 32 + .../features/users/controllers/e2e_test.go | 263 ++++ .../controllers/management_controller.go | 260 ++++ .../controllers/management_controller_test.go | 808 ++++++++++ .../users/controllers/settings_controller.go | 81 + .../controllers/settings_controller_test.go | 182 +++ .../users/controllers/user_controller.go | 343 +++++ .../users/controllers/user_controller_test.go | 1148 ++++++++++++++ backend/internal/features/users/di.go | 26 - backend/internal/features/users/dto.go | 18 - backend/internal/features/users/dto/dto.go | 94 ++ .../features/users/enums/user_role.go | 14 +- .../features/users/enums/user_status.go | 9 + .../features/users/enums/workspace_role.go | 20 + .../features/users/interfaces/interfaces.go | 9 + .../features/users/middleware/middleware.go | 75 + .../features/users/models/secret_key.go | 2 +- .../internal/features/users/models/user.go | 50 +- .../features/users/models/users_settings.go | 17 + .../repositories/secret_key_repository.go | 2 +- .../users/repositories/user_repository.go | 195 ++- .../repositories/users_settings_repository.go | 47 + backend/internal/features/users/service.go | 175 --- .../internal/features/users/services/di.go | 36 + .../users/services/management_service.go | 166 +++ .../features/users/services/oauth_testing.go | 23 + .../users/services/settings_service.go | 80 + .../features/users/services/user_services.go | 798 ++++++++++ backend/internal/features/users/testing.go | 31 - .../features/users/testing/settings_utils.go | 68 + .../features/users/testing/user_utils.go | 78 + .../features/workspaces/controllers/di.go | 21 + .../workspaces/controllers/e2e_test.go | 279 ++++ .../controllers/membership_controller.go | 265 ++++ .../controllers/membership_controller_test.go | 1315 +++++++++++++++++ .../controllers/workspace_controller.go | 266 ++++ .../controllers/workspace_controller_test.go | 706 +++++++++ .../internal/features/workspaces/dto/dto.go | 65 + .../workspaces/interfaces/interfaces.go | 7 + .../features/workspaces/models/workspace.go | 21 + .../workspaces/models/workspace_membership.go | 21 + .../repositories/membership_repository.go | 133 ++ .../repositories/workspace_repository.go | 52 + .../features/workspaces/services/di.go | 37 + .../workspaces/services/membership_service.go | 329 +++++ .../workspaces/services/workspace_service.go | 316 ++++ .../features/workspaces/testing/interfaces.go | 7 + .../features/workspaces/testing/testing.go | 477 ++++++ ...1031143019_add_many_users_and_settings.sql | 66 + .../20251031145001_add_system_audit_logs.sql | 42 + .../20251031155004_add_workspaces.sql | 59 + ...1031155005_connect_workspaces_with_dbs.sql | 56 + ...000_workspace_scope_storages_notifiers.sql | 68 + contribute/README.md | 27 +- frontend/public/favicon.svg | 4 +- .../public/icons/menu/arrow-down-gray.svg | 3 + .../icons/menu/global-settings-gray.svg | 4 + .../icons/menu/global-settings-white.svg | 4 + frontend/public/icons/menu/profile-gray.svg | 3 + frontend/public/icons/menu/profile-white.svg | 3 + frontend/public/icons/menu/user-card-gray.svg | 3 + .../public/icons/menu/user-card-white.svg | 3 + frontend/public/icons/menu/users-gray.svg | 4 + frontend/public/icons/menu/users-white.svg | 4 + .../icons/menu/workspace-settings-gray.svg | 15 + .../icons/menu/workspace-settings-white.svg | 15 + frontend/public/logo.svg | 4 +- frontend/src/App.tsx | 31 +- frontend/src/constants.ts | 30 + .../src/entity/audit-logs/api/auditLogApi.ts | 56 + frontend/src/entity/audit-logs/index.ts | 7 + .../src/entity/audit-logs/model/AuditLog.ts | 10 + .../audit-logs/model/GetAuditLogsRequest.ts | 5 + .../audit-logs/model/GetAuditLogsResponse.ts | 8 + .../src/entity/databases/api/databaseApi.ts | 4 +- .../src/entity/databases/model/Database.ts | 1 + .../src/entity/notifiers/api/notifierApi.ts | 4 +- .../src/entity/notifiers/models/Notifier.ts | 1 + .../src/entity/storages/api/storageApi.ts | 4 +- .../src/entity/storages/models/Storage.ts | 1 + frontend/src/entity/users/api/settingsApi.ts | 23 + frontend/src/entity/users/api/userApi.ts | 80 + .../src/entity/users/api/userManagementApi.ts | 71 + frontend/src/entity/users/index.ts | 21 + .../users/model/ChangePasswordRequest.ts | 3 + .../users/model/ChangeUserRoleRequest.ts | 5 + .../entity/users/model/InviteUserRequest.ts | 7 + .../entity/users/model/InviteUserResponse.ts | 9 + .../users/model/IsAdminHasPasswordResponse.ts | 3 + .../entity/users/model/ListUsersRequest.ts | 6 + .../entity/users/model/ListUsersResponse.ts | 6 + .../users/model/OAuthCallbackRequest.ts | 4 + .../users/model/OAuthCallbackResponse.ts | 6 + .../users/model/SetAdminPasswordRequest.ts | 3 + .../src/entity/users/model/SignUpRequest.ts | 1 + .../users/model/UpdateUserInfoRequest.ts | 4 + .../src/entity/users/model/UserProfile.ts | 10 + frontend/src/entity/users/model/UserRole.ts | 4 + .../src/entity/users/model/UsersSettings.ts | 5 + .../src/entity/users/model/WorkspaceRole.ts | 6 + .../src/entity/workspaces/api/workspaceApi.ts | 76 + .../workspaces/api/workspaceMembershipApi.ts | 60 + frontend/src/entity/workspaces/index.ts | 17 + .../workspaces/model/AddMemberRequest.ts | 6 + .../workspaces/model/AddMemberResponse.ts | 5 + .../workspaces/model/AddMemberStatus.ts | 4 + .../model/ChangeMemberRoleRequest.ts | 5 + .../model/CreateWorkspaceRequest.ts | 3 + .../workspaces/model/GetMembersResponse.ts | 5 + .../model/ListWorkspacesResponse.ts | 5 + .../model/TransferOwnershipRequest.ts | 3 + .../src/entity/workspaces/model/Workspace.ts | 5 + .../model/WorkspaceMemberResponse.ts | 10 + .../workspaces/model/WorkspaceMembership.ts | 9 + .../workspaces/model/WorkspaceResponse.ts | 8 + .../features/backups/ui/BackupsComponent.tsx | 31 +- .../backups/ui/EditBackupConfigComponent.tsx | 5 +- .../databases/ui/CreateDatabaseComponent.tsx | 9 +- .../databases/ui/DatabaseCardComponent.tsx | 25 +- .../databases/ui/DatabaseComponent.tsx | 5 +- .../databases/ui/DatabaseConfigComponent.tsx | 21 +- .../databases/ui/DatabasesComponent.tsx | 14 +- .../edit/EditDatabaseNotifiersComponent.tsx | 5 +- .../EditDatabaseSpecificDataComponent.tsx | 22 - .../ShowDatabaseSpecificDataComponent.tsx | 9 - .../notifiers/ui/NotifierComponent.tsx | 41 +- .../notifiers/ui/NotifiersComponent.tsx | 14 +- .../ui/edit/EditNotifierComponent.tsx | 5 + frontend/src/features/settings/index.ts | 1 + .../settings/ui/AuditLogsComponent.tsx | 205 +++ .../settings/ui/SettingsComponent.tsx | 267 ++++ .../storages/OauthStorageComponent.tsx | 104 -- frontend/src/features/storages/index.ts | 1 + .../{ => ui}/StorageCardComponent.tsx | 6 +- .../storages/{ => ui}/StorageComponent.tsx | 57 +- .../storages/{ => ui}/StoragesComponent.tsx | 19 +- .../storages/ui/edit/EditStorageComponent.tsx | 4 + frontend/src/features/users/index.ts | 2 + .../users/ui/AdminPasswordComponent.tsx | 141 ++ .../features/users/ui/AuthNavbarComponent.tsx | 12 +- .../src/features/users/ui/OauthComponent.tsx | 96 ++ .../features/users/ui/ProfileComponent.tsx | 341 +++++ .../src/features/users/ui/SignInComponent.tsx | 39 +- .../src/features/users/ui/SignUpComponent.tsx | 63 +- .../ui/UserAuditLogsSidebarComponent.tsx | 189 +++ .../src/features/users/ui/UsersComponent.tsx | 366 +++++ frontend/src/features/workspaces/index.ts | 4 + .../ui/CreateWorkspaceDialogComponent.tsx | 126 ++ .../ui/WorkspaceAuditLogsComponent.tsx | 204 +++ .../ui/WorkspaceMembershipComponent.tsx | 651 ++++++++ .../ui/WorkspaceSettingsComponent.tsx | 280 ++++ frontend/src/index.css | 26 + frontend/src/pages/AuthPageComponent.tsx | 46 +- frontend/src/pages/OAuthCallbackPage.tsx | 76 + frontend/src/shared/lib/StringUtils.ts | 5 + frontend/src/shared/lib/index.ts | 1 + frontend/src/shared/time/index.ts | 2 +- .../src/widgets/main/MainScreenComponent.tsx | 376 +++-- .../main/WorkspaceSelectionComponent.tsx | 135 ++ 227 files changed, 19472 insertions(+), 1545 deletions(-) delete mode 100644 backend/internal/downdetect/controller.go delete mode 100644 backend/internal/downdetect/di.go delete mode 100644 backend/internal/downdetect/service.go create mode 100644 backend/internal/features/audit_logs/controller.go create mode 100644 backend/internal/features/audit_logs/controller_test.go create mode 100644 backend/internal/features/audit_logs/di.go create mode 100644 backend/internal/features/audit_logs/dto.go create mode 100644 backend/internal/features/audit_logs/models.go create mode 100644 backend/internal/features/audit_logs/repository.go create mode 100644 backend/internal/features/audit_logs/service.go create mode 100644 backend/internal/features/audit_logs/service_test.go create mode 100644 backend/internal/features/backups/backups/controller_test.go create mode 100644 backend/internal/features/backups/backups/testing.go create mode 100644 backend/internal/features/backups/config/controller_test.go create mode 100644 backend/internal/features/databases/controller_test.go create mode 100644 backend/internal/features/healthcheck/attempt/controller_test.go create mode 100644 backend/internal/features/healthcheck/config/controller_test.go create mode 100644 backend/internal/features/notifiers/controller_test.go create mode 100644 backend/internal/features/restores/controller_test.go delete mode 100644 backend/internal/features/users/controller.go create mode 100644 backend/internal/features/users/controllers/di.go create mode 100644 backend/internal/features/users/controllers/e2e_test.go create mode 100644 backend/internal/features/users/controllers/management_controller.go create mode 100644 backend/internal/features/users/controllers/management_controller_test.go create mode 100644 backend/internal/features/users/controllers/settings_controller.go create mode 100644 backend/internal/features/users/controllers/settings_controller_test.go create mode 100644 backend/internal/features/users/controllers/user_controller.go create mode 100644 backend/internal/features/users/controllers/user_controller_test.go delete mode 100644 backend/internal/features/users/di.go delete mode 100644 backend/internal/features/users/dto.go create mode 100644 backend/internal/features/users/dto/dto.go create mode 100644 backend/internal/features/users/enums/user_status.go create mode 100644 backend/internal/features/users/enums/workspace_role.go create mode 100644 backend/internal/features/users/interfaces/interfaces.go create mode 100644 backend/internal/features/users/middleware/middleware.go create mode 100644 backend/internal/features/users/models/users_settings.go create mode 100644 backend/internal/features/users/repositories/users_settings_repository.go delete mode 100644 backend/internal/features/users/service.go create mode 100644 backend/internal/features/users/services/di.go create mode 100644 backend/internal/features/users/services/management_service.go create mode 100644 backend/internal/features/users/services/oauth_testing.go create mode 100644 backend/internal/features/users/services/settings_service.go create mode 100644 backend/internal/features/users/services/user_services.go delete mode 100644 backend/internal/features/users/testing.go create mode 100644 backend/internal/features/users/testing/settings_utils.go create mode 100644 backend/internal/features/users/testing/user_utils.go create mode 100644 backend/internal/features/workspaces/controllers/di.go create mode 100644 backend/internal/features/workspaces/controllers/e2e_test.go create mode 100644 backend/internal/features/workspaces/controllers/membership_controller.go create mode 100644 backend/internal/features/workspaces/controllers/membership_controller_test.go create mode 100644 backend/internal/features/workspaces/controllers/workspace_controller.go create mode 100644 backend/internal/features/workspaces/controllers/workspace_controller_test.go create mode 100644 backend/internal/features/workspaces/dto/dto.go create mode 100644 backend/internal/features/workspaces/interfaces/interfaces.go create mode 100644 backend/internal/features/workspaces/models/workspace.go create mode 100644 backend/internal/features/workspaces/models/workspace_membership.go create mode 100644 backend/internal/features/workspaces/repositories/membership_repository.go create mode 100644 backend/internal/features/workspaces/repositories/workspace_repository.go create mode 100644 backend/internal/features/workspaces/services/di.go create mode 100644 backend/internal/features/workspaces/services/membership_service.go create mode 100644 backend/internal/features/workspaces/services/workspace_service.go create mode 100644 backend/internal/features/workspaces/testing/interfaces.go create mode 100644 backend/internal/features/workspaces/testing/testing.go create mode 100644 backend/migrations/20251031143019_add_many_users_and_settings.sql create mode 100644 backend/migrations/20251031145001_add_system_audit_logs.sql create mode 100644 backend/migrations/20251031155004_add_workspaces.sql create mode 100644 backend/migrations/20251031155005_connect_workspaces_with_dbs.sql create mode 100644 backend/migrations/20251104000000_workspace_scope_storages_notifiers.sql create mode 100644 frontend/public/icons/menu/arrow-down-gray.svg create mode 100644 frontend/public/icons/menu/global-settings-gray.svg create mode 100644 frontend/public/icons/menu/global-settings-white.svg create mode 100644 frontend/public/icons/menu/profile-gray.svg create mode 100644 frontend/public/icons/menu/profile-white.svg create mode 100644 frontend/public/icons/menu/user-card-gray.svg create mode 100644 frontend/public/icons/menu/user-card-white.svg create mode 100644 frontend/public/icons/menu/users-gray.svg create mode 100644 frontend/public/icons/menu/users-white.svg create mode 100644 frontend/public/icons/menu/workspace-settings-gray.svg create mode 100644 frontend/public/icons/menu/workspace-settings-white.svg create mode 100644 frontend/src/entity/audit-logs/api/auditLogApi.ts create mode 100644 frontend/src/entity/audit-logs/index.ts create mode 100644 frontend/src/entity/audit-logs/model/AuditLog.ts create mode 100644 frontend/src/entity/audit-logs/model/GetAuditLogsRequest.ts create mode 100644 frontend/src/entity/audit-logs/model/GetAuditLogsResponse.ts create mode 100644 frontend/src/entity/users/api/settingsApi.ts create mode 100644 frontend/src/entity/users/api/userManagementApi.ts create mode 100644 frontend/src/entity/users/model/ChangePasswordRequest.ts create mode 100644 frontend/src/entity/users/model/ChangeUserRoleRequest.ts create mode 100644 frontend/src/entity/users/model/InviteUserRequest.ts create mode 100644 frontend/src/entity/users/model/InviteUserResponse.ts create mode 100644 frontend/src/entity/users/model/IsAdminHasPasswordResponse.ts create mode 100644 frontend/src/entity/users/model/ListUsersRequest.ts create mode 100644 frontend/src/entity/users/model/ListUsersResponse.ts create mode 100644 frontend/src/entity/users/model/OAuthCallbackRequest.ts create mode 100644 frontend/src/entity/users/model/OAuthCallbackResponse.ts create mode 100644 frontend/src/entity/users/model/SetAdminPasswordRequest.ts create mode 100644 frontend/src/entity/users/model/UpdateUserInfoRequest.ts create mode 100644 frontend/src/entity/users/model/UserProfile.ts create mode 100644 frontend/src/entity/users/model/UserRole.ts create mode 100644 frontend/src/entity/users/model/UsersSettings.ts create mode 100644 frontend/src/entity/users/model/WorkspaceRole.ts create mode 100644 frontend/src/entity/workspaces/api/workspaceApi.ts create mode 100644 frontend/src/entity/workspaces/api/workspaceMembershipApi.ts create mode 100644 frontend/src/entity/workspaces/index.ts create mode 100644 frontend/src/entity/workspaces/model/AddMemberRequest.ts create mode 100644 frontend/src/entity/workspaces/model/AddMemberResponse.ts create mode 100644 frontend/src/entity/workspaces/model/AddMemberStatus.ts create mode 100644 frontend/src/entity/workspaces/model/ChangeMemberRoleRequest.ts create mode 100644 frontend/src/entity/workspaces/model/CreateWorkspaceRequest.ts create mode 100644 frontend/src/entity/workspaces/model/GetMembersResponse.ts create mode 100644 frontend/src/entity/workspaces/model/ListWorkspacesResponse.ts create mode 100644 frontend/src/entity/workspaces/model/TransferOwnershipRequest.ts create mode 100644 frontend/src/entity/workspaces/model/Workspace.ts create mode 100644 frontend/src/entity/workspaces/model/WorkspaceMemberResponse.ts create mode 100644 frontend/src/entity/workspaces/model/WorkspaceMembership.ts create mode 100644 frontend/src/entity/workspaces/model/WorkspaceResponse.ts create mode 100644 frontend/src/features/settings/index.ts create mode 100644 frontend/src/features/settings/ui/AuditLogsComponent.tsx create mode 100644 frontend/src/features/settings/ui/SettingsComponent.tsx delete mode 100644 frontend/src/features/storages/OauthStorageComponent.tsx create mode 100644 frontend/src/features/storages/index.ts rename frontend/src/features/storages/{ => ui}/StorageCardComponent.tsx (81%) rename frontend/src/features/storages/{ => ui}/StorageComponent.tsx (84%) rename frontend/src/features/storages/{ => ui}/StoragesComponent.tsx (81%) create mode 100644 frontend/src/features/users/ui/AdminPasswordComponent.tsx create mode 100644 frontend/src/features/users/ui/OauthComponent.tsx create mode 100644 frontend/src/features/users/ui/ProfileComponent.tsx create mode 100644 frontend/src/features/users/ui/UserAuditLogsSidebarComponent.tsx create mode 100644 frontend/src/features/users/ui/UsersComponent.tsx create mode 100644 frontend/src/features/workspaces/index.ts create mode 100644 frontend/src/features/workspaces/ui/CreateWorkspaceDialogComponent.tsx create mode 100644 frontend/src/features/workspaces/ui/WorkspaceAuditLogsComponent.tsx create mode 100644 frontend/src/features/workspaces/ui/WorkspaceMembershipComponent.tsx create mode 100644 frontend/src/features/workspaces/ui/WorkspaceSettingsComponent.tsx create mode 100644 frontend/src/pages/OAuthCallbackPage.tsx create mode 100644 frontend/src/shared/lib/StringUtils.ts create mode 100644 frontend/src/widgets/main/WorkspaceSelectionComponent.tsx diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 1901df2..282ddf9 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -138,6 +138,9 @@ jobs: TEST_MINIO_CONSOLE_PORT=9001 # testing NAS TEST_NAS_PORT=7006 + # testing Telegram + TEST_TELEGRAM_BOT_TOKEN=${{ secrets.TEST_TELEGRAM_BOT_TOKEN }} + TEST_TELEGRAM_CHAT_ID=${{ secrets.TEST_TELEGRAM_CHAT_ID }} EOF - name: Start test containers diff --git a/LICENSE b/LICENSE index d1e0d1b..ee130ce 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "license" line as the copyright notice for easier identification within third-party archives. - Copyright 2025 LogBull + Copyright 2025 Postgresus Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 123c023..59b55d3 100644 --- a/README.md +++ b/README.md @@ -154,9 +154,11 @@ docker compose up -d If you need to reset the admin password, you can use the built-in password reset command: ```bash -docker exec -it postgresus ./main --new-password="YourNewSecurePassword123" +docker exec -it postgresus ./main --new-password="YourNewSecurePassword123" --email="admin" ``` +Replace `admin` with the actual email address of the user whose password you want to reset. + --- ## 📝 License diff --git a/backend/.cursor/rules/controllers-rule.mdc b/backend/.cursor/rules/controllers-rule.mdc index eebfd68..cf20029 100644 --- a/backend/.cursor/rules/controllers-rule.mdc +++ b/backend/.cursor/rules/controllers-rule.mdc @@ -1,14 +1,16 @@ --- -description: -globs: +description: +globs: alwaysApply: true --- + 1. When we write controller: + - we combine all routes to single controller - names them as .WhatWeDo (not "handlers") concept -2. We use gin and *gin.Context for all routes. -Example: +2. We use gin and \*gin.Context for all routes. + Example: func (c *TasksController) GetAvailableTasks(ctx *gin.Context) ... @@ -17,24 +19,26 @@ func (c *TasksController) GetAvailableTasks(ctx *gin.Context) ... package audit_logs import ( - "net/http" +"net/http" - user_models "logbull/internal/features/users/models" + user_models "postgresus/internal/features/users/models" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" - "github.com/gin-gonic/gin" - "github.com/google/uuid" ) type AuditLogController struct { - auditLogService *AuditLogService +auditLogService \*AuditLogService } func (c *AuditLogController) RegisterRoutes(router *gin.RouterGroup) { - // All audit log endpoints require authentication (handled in main.go) - auditRoutes := router.Group("/audit-logs") +// All audit log endpoints require authentication (handled in main.go) +auditRoutes := router.Group("/audit-logs") + + auditRoutes.GET("/global", c.GetGlobalAuditLogs) + auditRoutes.GET("/users/:userId", c.GetUserAuditLogs) - auditRoutes.GET("/global", c.GetGlobalAuditLogs) - auditRoutes.GET("/users/:userId", c.GetUserAuditLogs) } // GetGlobalAuditLogs @@ -52,29 +56,30 @@ func (c *AuditLogController) RegisterRoutes(router *gin.RouterGroup) { // @Failure 403 {object} map[string]string // @Router /audit-logs/global [get] func (c *AuditLogController) GetGlobalAuditLogs(ctx *gin.Context) { - user, isOk := ctx.MustGet("user").(*user_models.User) - if !isOk { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user type in context"}) - return - } +user, isOk := ctx.MustGet("user").(\*user_models.User) +if !isOk { +ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user type in context"}) +return +} - request := &GetAuditLogsRequest{} - if err := ctx.ShouldBindQuery(request); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) - return - } + request := &GetAuditLogsRequest{} + if err := ctx.ShouldBindQuery(request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) + return + } - response, err := c.auditLogService.GetGlobalAuditLogs(user, request) - if err != nil { - if err.Error() == "only administrators can view global audit logs" { - ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve audit logs"}) - return - } + response, err := c.auditLogService.GetGlobalAuditLogs(user, request) + if err != nil { + if err.Error() == "only administrators can view global audit logs" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve audit logs"}) + return + } + + ctx.JSON(http.StatusOK, response) - ctx.JSON(http.StatusOK, response) } // GetUserAuditLogs @@ -94,34 +99,35 @@ func (c *AuditLogController) GetGlobalAuditLogs(ctx *gin.Context) { // @Failure 403 {object} map[string]string // @Router /audit-logs/users/{userId} [get] func (c *AuditLogController) GetUserAuditLogs(ctx *gin.Context) { - user, isOk := ctx.MustGet("user").(*user_models.User) - if !isOk { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user type in context"}) - return - } - - userIDStr := ctx.Param("userId") - targetUserID, err := uuid.Parse(userIDStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) - return - } - - request := &GetAuditLogsRequest{} - if err := ctx.ShouldBindQuery(request); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) - return - } - - response, err := c.auditLogService.GetUserAuditLogs(targetUserID, user, request) - if err != nil { - if err.Error() == "insufficient permissions to view user audit logs" { - ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve audit logs"}) - return - } - - ctx.JSON(http.StatusOK, response) +user, isOk := ctx.MustGet("user").(\*user_models.User) +if !isOk { +ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user type in context"}) +return +} + + userIDStr := ctx.Param("userId") + targetUserID, err := uuid.Parse(userIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + request := &GetAuditLogsRequest{} + if err := ctx.ShouldBindQuery(request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) + return + } + + response, err := c.auditLogService.GetUserAuditLogs(targetUserID, user, request) + if err != nil { + if err.Error() == "insufficient permissions to view user audit logs" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve audit logs"}) + return + } + + ctx.JSON(http.StatusOK, response) + } diff --git a/backend/.cursor/rules/crud.mdc b/backend/.cursor/rules/crud.mdc index bcc16cd..a136571 100644 --- a/backend/.cursor/rules/crud.mdc +++ b/backend/.cursor/rules/crud.mdc @@ -1,16 +1,18 @@ --- alwaysApply: false --- + This is example of CRUD: ------ backend/internal/features/audit_logs/controller.go ------ -`````` + +``` package audit_logs import ( "net/http" - user_models "logbull/internal/features/users/models" + user_models "postgresus/internal/features/users/models" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -117,9 +119,11 @@ func (c *AuditLogController) GetUserAuditLogs(ctx *gin.Context) { ctx.JSON(http.StatusOK, response) } -`````` +``` + ------ backend/internal/features/audit_logs/controller_test.go ------ -`````` + +``` package audit_logs import ( @@ -128,12 +132,12 @@ import ( "testing" "time" - user_enums "logbull/internal/features/users/enums" - users_middleware "logbull/internal/features/users/middleware" - users_services "logbull/internal/features/users/services" - users_testing "logbull/internal/features/users/testing" - "logbull/internal/storage" - test_utils "logbull/internal/util/testing" + user_enums "postgresus/internal/features/users/enums" + users_middleware "postgresus/internal/features/users/middleware" + users_services "postgresus/internal/features/users/services" + users_testing "postgresus/internal/features/users/testing" + "postgresus/internal/storage" + test_utils "postgresus/internal/util/testing" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -256,14 +260,16 @@ func createRouter() *gin.Engine { return router } -`````` +``` + ------ backend/internal/features/audit_logs/di.go ------ -`````` + +``` package audit_logs import ( - users_services "logbull/internal/features/users/services" - "logbull/internal/util/logger" + users_services "postgresus/internal/features/users/services" + "postgresus/internal/util/logger" ) var auditLogRepository = &AuditLogRepository{} @@ -289,9 +295,11 @@ func SetupDependencies() { users_services.GetManagementService().SetAuditLogWriter(auditLogService) } -`````` +``` + ------ backend/internal/features/audit_logs/dto.go ------ -`````` + +``` package audit_logs import "time" @@ -309,9 +317,11 @@ type GetAuditLogsResponse struct { Offset int `json:"offset"` } -`````` +``` + ------ backend/internal/features/audit_logs/models.go ------ -`````` + +``` package audit_logs import ( @@ -332,13 +342,15 @@ func (AuditLog) TableName() string { return "audit_logs" } -`````` +``` + ------ backend/internal/features/audit_logs/repository.go ------ -`````` + +``` package audit_logs import ( - "logbull/internal/storage" + "postgresus/internal/storage" "time" "github.com/google/uuid" @@ -429,9 +441,11 @@ func (r *AuditLogRepository) CountGlobal(beforeDate *time.Time) (int64, error) { return count, err } -`````` +``` + ------ backend/internal/features/audit_logs/service.go ------ -`````` + +``` package audit_logs import ( @@ -439,8 +453,8 @@ import ( "log/slog" "time" - user_enums "logbull/internal/features/users/enums" - user_models "logbull/internal/features/users/models" + user_enums "postgresus/internal/features/users/enums" + user_models "postgresus/internal/features/users/models" "github.com/google/uuid" ) @@ -560,17 +574,19 @@ func (s *AuditLogService) GetProjectAuditLogs( }, nil } -`````` +``` + ------ backend/internal/features/audit_logs/service_test.go ------ -`````` + +``` package audit_logs import ( "testing" "time" - user_enums "logbull/internal/features/users/enums" - users_testing "logbull/internal/features/users/testing" + user_enums "postgresus/internal/features/users/enums" + users_testing "postgresus/internal/features/users/testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -652,4 +668,4 @@ func createTimedLog(db *gorm.DB, userID *uuid.UUID, message string, createdAt ti db.Create(log) } -`````` +``` diff --git a/backend/.env.development.example b/backend/.env.development.example index 0cde011..11264b1 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -27,4 +27,7 @@ TEST_POSTGRES_18_PORT=5006 TEST_MINIO_PORT=9000 TEST_MINIO_CONSOLE_PORT=9001 # testing NAS -TEST_NAS_PORT=7006 \ No newline at end of file +TEST_NAS_PORT=7006 +# testing Telegram +TEST_TELEGRAM_BOT_TOKEN= +TEST_TELEGRAM_CHAT_ID= \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore index eb0bdc5..0f645f6 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -12,4 +12,5 @@ swagger/swagger.yaml postgresus-backend.exe ui/build/* pgdata-for-restore/ -temp/ \ No newline at end of file +temp/ +cmd.exe \ No newline at end of file diff --git a/backend/cmd/main.go b/backend/cmd/main.go index ef09ac9..99c4a29 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -13,7 +13,7 @@ import ( "time" "postgresus-backend/internal/config" - "postgresus-backend/internal/downdetect" + "postgresus-backend/internal/features/audit_logs" "postgresus-backend/internal/features/backups/backups" backups_config "postgresus-backend/internal/features/backups/config" "postgresus-backend/internal/features/databases" @@ -24,7 +24,10 @@ import ( "postgresus-backend/internal/features/restores" "postgresus-backend/internal/features/storages" system_healthcheck "postgresus-backend/internal/features/system/healthcheck" - "postgresus-backend/internal/features/users" + users_controllers "postgresus-backend/internal/features/users/controllers" + users_middleware "postgresus-backend/internal/features/users/middleware" + users_services "postgresus-backend/internal/features/users/services" + workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers" env_utils "postgresus-backend/internal/util/env" files_utils "postgresus-backend/internal/util/files" "postgresus-backend/internal/util/logger" @@ -61,13 +64,14 @@ func main() { os.Exit(1) } - // Handle password reset if flag is provided - newPassword := flag.String("new-password", "", "Set a new password for the user") - flag.Parse() - if *newPassword != "" { - resetPassword(*newPassword, log) + err = users_services.GetUserService().CreateInitialAdmin() + if err != nil { + log.Error("Failed to create initial admin", "error", err) + os.Exit(1) } + handlePasswordReset(log) + go generateSwaggerDocs(log) gin.SetMode(gin.ReleaseMode) @@ -91,11 +95,33 @@ func main() { startServerWithGracefulShutdown(log, ginApp) } -func resetPassword(newPassword string, log *slog.Logger) { +func handlePasswordReset(log *slog.Logger) { + audit_logs.SetupDependencies() + + newPassword := flag.String("new-password", "", "Set a new password for the user") + email := flag.String("email", "", "Email of the user to reset password") + + flag.Parse() + + if *newPassword == "" { + return + } + + log.Info("Found reset password command - reseting password...") + + if *email == "" { + log.Info("No email provided, please provide an email via --email=\"some@email.com\" flag") + os.Exit(1) + } + + resetPassword(*email, *newPassword, log) +} + +func resetPassword(email string, newPassword string, log *slog.Logger) { log.Info("Resetting password...") - userService := users.GetUserService() - err := userService.ChangePassword(newPassword) + userService := users_services.GetUserService() + err := userService.ChangeUserPasswordByEmail(email, newPassword) if err != nil { log.Error("Failed to reset password", "error", err) os.Exit(1) @@ -146,37 +172,44 @@ func setUpRoutes(r *gin.Engine) { // Mount Swagger UI v1.GET("/docs/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) - downdetectContoller := downdetect.GetDowndetectController() - userController := users.GetUserController() - notifierController := notifiers.GetNotifierController() - storageController := storages.GetStorageController() - databaseController := databases.GetDatabaseController() - backupController := backups.GetBackupController() - restoreController := restores.GetRestoreController() - healthcheckController := system_healthcheck.GetHealthcheckController() - healthcheckConfigController := healthcheck_config.GetHealthcheckConfigController() - healthcheckAttemptController := healthcheck_attempt.GetHealthcheckAttemptController() - diskController := disk.GetDiskController() - backupConfigController := backups_config.GetBackupConfigController() - - downdetectContoller.RegisterRoutes(v1) + // Public routes (only user auth routes and healthcheck should be public) + userController := users_controllers.GetUserController() userController.RegisterRoutes(v1) - notifierController.RegisterRoutes(v1) - storageController.RegisterRoutes(v1) - databaseController.RegisterRoutes(v1) - backupController.RegisterRoutes(v1) - restoreController.RegisterRoutes(v1) - healthcheckController.RegisterRoutes(v1) - diskController.RegisterRoutes(v1) - healthcheckConfigController.RegisterRoutes(v1) - healthcheckAttemptController.RegisterRoutes(v1) - backupConfigController.RegisterRoutes(v1) + system_healthcheck.GetHealthcheckController().RegisterRoutes(v1) + + // Setup auth middleware + userService := users_services.GetUserService() + authMiddleware := users_middleware.AuthMiddleware(userService) + + // Protected routes + protected := v1.Group("") + protected.Use(authMiddleware) + + userController.RegisterProtectedRoutes(protected) + workspaces_controllers.GetWorkspaceController().RegisterRoutes(protected) + workspaces_controllers.GetMembershipController().RegisterRoutes(protected) + disk.GetDiskController().RegisterRoutes(protected) + notifiers.GetNotifierController().RegisterRoutes(protected) + storages.GetStorageController().RegisterRoutes(protected) + databases.GetDatabaseController().RegisterRoutes(protected) + backups.GetBackupController().RegisterRoutes(protected) + restores.GetRestoreController().RegisterRoutes(protected) + healthcheck_config.GetHealthcheckConfigController().RegisterRoutes(protected) + healthcheck_attempt.GetHealthcheckAttemptController().RegisterRoutes(protected) + backups_config.GetBackupConfigController().RegisterRoutes(protected) + audit_logs.GetAuditLogController().RegisterRoutes(protected) + users_controllers.GetManagementController().RegisterRoutes(protected) + users_controllers.GetSettingsController().RegisterRoutes(protected) } func setUpDependencies() { + databases.SetupDependencies() backups.SetupDependencies() restores.SetupDependencies() healthcheck_config.SetupDependencies() + audit_logs.SetupDependencies() + notifiers.SetupDependencies() + storages.SetupDependencies() } func runBackgroundTasks(log *slog.Logger) { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 52b110a..3267427 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -44,6 +44,16 @@ type EnvVariables struct { TestMinioConsolePort string `env:"TEST_MINIO_CONSOLE_PORT"` TestNASPort string `env:"TEST_NAS_PORT"` + + // oauth + GitHubClientID string `env:"GITHUB_CLIENT_ID"` + GitHubClientSecret string `env:"GITHUB_CLIENT_SECRET"` + GoogleClientID string `env:"GOOGLE_CLIENT_ID"` + GoogleClientSecret string `env:"GOOGLE_CLIENT_SECRET"` + + // testing Telegram + TestTelegramBotToken string `env:"TEST_TELEGRAM_BOT_TOKEN"` + TestTelegramChatID string `env:"TEST_TELEGRAM_CHAT_ID"` } var ( @@ -173,6 +183,16 @@ func loadEnvVariables() { log.Error("TEST_NAS_PORT is empty") os.Exit(1) } + + if env.TestTelegramBotToken == "" { + log.Error("TEST_TELEGRAM_BOT_TOKEN is empty") + os.Exit(1) + } + + if env.TestTelegramChatID == "" { + log.Error("TEST_TELEGRAM_CHAT_ID is empty") + os.Exit(1) + } } log.Info("Environment variables loaded successfully!") diff --git a/backend/internal/downdetect/controller.go b/backend/internal/downdetect/controller.go deleted file mode 100644 index 9abddb6..0000000 --- a/backend/internal/downdetect/controller.go +++ /dev/null @@ -1,37 +0,0 @@ -package downdetect - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" -) - -type DowndetectController struct { - service *DowndetectService -} - -func (c *DowndetectController) RegisterRoutes(router *gin.RouterGroup) { - router.GET("/downdetect/is-available", c.IsAvailable) -} - -// @Summary Check API availability -// @Description Checks if the API service is available -// @Tags downdetect -// @Accept json -// @Produce json -// @Success 200 -// @Failure 500 -// @Router /downdetect/api [get] -func (c *DowndetectController) IsAvailable(ctx *gin.Context) { - err := c.service.IsDbAvailable() - if err != nil { - ctx.JSON( - http.StatusInternalServerError, - gin.H{"error": fmt.Sprintf("Database is not available: %v", err)}, - ) - return - } - - ctx.JSON(http.StatusOK, gin.H{"message": "API and DB are available"}) -} diff --git a/backend/internal/downdetect/di.go b/backend/internal/downdetect/di.go deleted file mode 100644 index f11a15a..0000000 --- a/backend/internal/downdetect/di.go +++ /dev/null @@ -1,10 +0,0 @@ -package downdetect - -var downdetectService = &DowndetectService{} -var downdetectController = &DowndetectController{ - downdetectService, -} - -func GetDowndetectController() *DowndetectController { - return downdetectController -} diff --git a/backend/internal/downdetect/service.go b/backend/internal/downdetect/service.go deleted file mode 100644 index 6b7a4cd..0000000 --- a/backend/internal/downdetect/service.go +++ /dev/null @@ -1,17 +0,0 @@ -package downdetect - -import ( - "postgresus-backend/internal/storage" -) - -type DowndetectService struct { -} - -func (s *DowndetectService) IsDbAvailable() error { - err := storage.GetDb().Exec("SELECT 1").Error - if err != nil { - return err - } - - return nil -} diff --git a/backend/internal/features/audit_logs/controller.go b/backend/internal/features/audit_logs/controller.go new file mode 100644 index 0000000..3c4d774 --- /dev/null +++ b/backend/internal/features/audit_logs/controller.go @@ -0,0 +1,111 @@ +package audit_logs + +import ( + "net/http" + + user_models "postgresus-backend/internal/features/users/models" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type AuditLogController struct { + auditLogService *AuditLogService +} + +func (c *AuditLogController) RegisterRoutes(router *gin.RouterGroup) { + // All audit log endpoints require authentication (handled in main.go) + auditRoutes := router.Group("/audit-logs") + + auditRoutes.GET("/global", c.GetGlobalAuditLogs) + auditRoutes.GET("/users/:userId", c.GetUserAuditLogs) +} + +// GetGlobalAuditLogs +// @Summary Get global audit logs (ADMIN only) +// @Description Retrieve all audit logs across the system +// @Tags audit-logs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param limit query int false "Limit number of results" default(100) +// @Param offset query int false "Offset for pagination" default(0) +// @Param beforeDate query string false "Filter logs created before this date (RFC3339 format)" format(date-time) +// @Success 200 {object} GetAuditLogsResponse +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /audit-logs/global [get] +func (c *AuditLogController) GetGlobalAuditLogs(ctx *gin.Context) { + user, isOk := ctx.MustGet("user").(*user_models.User) + if !isOk { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user type in context"}) + return + } + + request := &GetAuditLogsRequest{} + if err := ctx.ShouldBindQuery(request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) + return + } + + response, err := c.auditLogService.GetGlobalAuditLogs(user, request) + if err != nil { + if err.Error() == "only administrators can view global audit logs" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve audit logs"}) + return + } + + ctx.JSON(http.StatusOK, response) +} + +// GetUserAuditLogs +// @Summary Get user audit logs +// @Description Retrieve audit logs for a specific user +// @Tags audit-logs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param userId path string true "User ID" +// @Param limit query int false "Limit number of results" default(100) +// @Param offset query int false "Offset for pagination" default(0) +// @Param beforeDate query string false "Filter logs created before this date (RFC3339 format)" format(date-time) +// @Success 200 {object} GetAuditLogsResponse +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /audit-logs/users/{userId} [get] +func (c *AuditLogController) GetUserAuditLogs(ctx *gin.Context) { + user, isOk := ctx.MustGet("user").(*user_models.User) + if !isOk { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user type in context"}) + return + } + + userIDStr := ctx.Param("userId") + targetUserID, err := uuid.Parse(userIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + request := &GetAuditLogsRequest{} + if err := ctx.ShouldBindQuery(request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) + return + } + + response, err := c.auditLogService.GetUserAuditLogs(targetUserID, user, request) + if err != nil { + if err.Error() == "insufficient permissions to view user audit logs" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve audit logs"}) + return + } + + ctx.JSON(http.StatusOK, response) +} diff --git a/backend/internal/features/audit_logs/controller_test.go b/backend/internal/features/audit_logs/controller_test.go new file mode 100644 index 0000000..cf72a95 --- /dev/null +++ b/backend/internal/features/audit_logs/controller_test.go @@ -0,0 +1,154 @@ +package audit_logs + +import ( + "fmt" + "net/http" + "testing" + "time" + + user_enums "postgresus-backend/internal/features/users/enums" + users_middleware "postgresus-backend/internal/features/users/middleware" + users_services "postgresus-backend/internal/features/users/services" + users_testing "postgresus-backend/internal/features/users/testing" + test_utils "postgresus-backend/internal/util/testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func Test_GetGlobalAuditLogs_WithDifferentUserRoles_EnforcesPermissionsCorrectly(t *testing.T) { + adminUser := users_testing.CreateTestUser(user_enums.UserRoleAdmin) + memberUser := users_testing.CreateTestUser(user_enums.UserRoleMember) + router := createRouter() + service := GetAuditLogService() + workspaceID := uuid.New() + testID := uuid.New().String() + + // Create test logs with unique identifiers + userLogMessage := fmt.Sprintf("Test log with user %s", testID) + workspaceLogMessage := fmt.Sprintf("Test log with workspace %s", testID) + standaloneLogMessage := fmt.Sprintf("Test log standalone %s", testID) + + createAuditLog(service, userLogMessage, &adminUser.UserID, nil) + createAuditLog(service, workspaceLogMessage, nil, &workspaceID) + createAuditLog(service, standaloneLogMessage, nil, nil) + + // Test ADMIN can access global logs + var response GetAuditLogsResponse + test_utils.MakeGetRequestAndUnmarshal(t, router, + "/api/v1/audit-logs/global?limit=100", "Bearer "+adminUser.Token, http.StatusOK, &response) + + // Verify our specific test logs are present + messages := extractMessages(response.AuditLogs) + assert.Contains(t, messages, userLogMessage) + assert.Contains(t, messages, workspaceLogMessage) + assert.Contains(t, messages, standaloneLogMessage) + + // Test MEMBER cannot access global logs + resp := test_utils.MakeGetRequest(t, router, "/api/v1/audit-logs/global", + "Bearer "+memberUser.Token, http.StatusForbidden) + assert.Contains(t, string(resp.Body), "only administrators can view global audit logs") +} + +func Test_GetUserAuditLogs_WithDifferentUserRoles_EnforcesPermissionsCorrectly(t *testing.T) { + adminUser := users_testing.CreateTestUser(user_enums.UserRoleAdmin) + user1 := users_testing.CreateTestUser(user_enums.UserRoleMember) + user2 := users_testing.CreateTestUser(user_enums.UserRoleMember) + router := createRouter() + service := GetAuditLogService() + workspaceID := uuid.New() + testID := uuid.New().String() + + // Create test logs for different users with unique identifiers + user1FirstMessage := fmt.Sprintf("Test log user1 first %s", testID) + user1SecondMessage := fmt.Sprintf("Test log user1 second %s", testID) + user2FirstMessage := fmt.Sprintf("Test log user2 first %s", testID) + user2SecondMessage := fmt.Sprintf("Test log user2 second %s", testID) + workspaceLogMessage := fmt.Sprintf("Test workspace log %s", testID) + + createAuditLog(service, user1FirstMessage, &user1.UserID, nil) + createAuditLog(service, user1SecondMessage, &user1.UserID, &workspaceID) + createAuditLog(service, user2FirstMessage, &user2.UserID, nil) + createAuditLog(service, user2SecondMessage, &user2.UserID, &workspaceID) + createAuditLog(service, workspaceLogMessage, nil, &workspaceID) + + // Test ADMIN can view any user's logs + var user1Response GetAuditLogsResponse + test_utils.MakeGetRequestAndUnmarshal(t, router, + fmt.Sprintf("/api/v1/audit-logs/users/%s?limit=100", user1.UserID.String()), + "Bearer "+adminUser.Token, http.StatusOK, &user1Response) + + // Verify user1's specific logs are present + messages := extractMessages(user1Response.AuditLogs) + assert.Contains(t, messages, user1FirstMessage) + assert.Contains(t, messages, user1SecondMessage) + + // Count only our test logs for user1 + testLogsCount := 0 + for _, message := range messages { + if message == user1FirstMessage || message == user1SecondMessage { + testLogsCount++ + } + } + assert.Equal(t, 2, testLogsCount) + + // Test user can view own logs + var ownLogsResponse GetAuditLogsResponse + test_utils.MakeGetRequestAndUnmarshal(t, router, + fmt.Sprintf("/api/v1/audit-logs/users/%s?limit=100", user2.UserID.String()), + "Bearer "+user2.Token, http.StatusOK, &ownLogsResponse) + + // Verify user2's specific logs are present + ownMessages := extractMessages(ownLogsResponse.AuditLogs) + assert.Contains(t, ownMessages, user2FirstMessage) + assert.Contains(t, ownMessages, user2SecondMessage) + + // Test user cannot view other user's logs + resp := test_utils.MakeGetRequest(t, router, + fmt.Sprintf("/api/v1/audit-logs/users/%s", user1.UserID.String()), + "Bearer "+user2.Token, http.StatusForbidden) + + assert.Contains(t, string(resp.Body), "insufficient permissions") +} + +func Test_GetGlobalAuditLogs_WithBeforeDateFilter_ReturnsFilteredLogs(t *testing.T) { + adminUser := users_testing.CreateTestUser(user_enums.UserRoleAdmin) + router := createRouter() + baseTime := time.Now().UTC() + + // Set filter time to 30 minutes ago + beforeTime := baseTime.Add(-30 * time.Minute) + + var filteredResponse GetAuditLogsResponse + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf( + "/api/v1/audit-logs/global?beforeDate=%s&limit=1000", + beforeTime.Format(time.RFC3339), + ), + "Bearer "+adminUser.Token, + http.StatusOK, + &filteredResponse, + ) + + // Verify ALL returned logs are older than the filter time + for _, log := range filteredResponse.AuditLogs { + assert.True(t, log.CreatedAt.Before(beforeTime), + fmt.Sprintf("Log created at %s should be before filter time %s", + log.CreatedAt.Format(time.RFC3339), beforeTime.Format(time.RFC3339))) + } +} + +func createRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + SetupDependencies() + + v1 := router.Group("/api/v1") + protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService())) + GetAuditLogController().RegisterRoutes(protected.(*gin.RouterGroup)) + + return router +} diff --git a/backend/internal/features/audit_logs/di.go b/backend/internal/features/audit_logs/di.go new file mode 100644 index 0000000..15966d2 --- /dev/null +++ b/backend/internal/features/audit_logs/di.go @@ -0,0 +1,29 @@ +package audit_logs + +import ( + users_services "postgresus-backend/internal/features/users/services" + "postgresus-backend/internal/util/logger" +) + +var auditLogRepository = &AuditLogRepository{} +var auditLogService = &AuditLogService{ + auditLogRepository: auditLogRepository, + logger: logger.GetLogger(), +} +var auditLogController = &AuditLogController{ + auditLogService: auditLogService, +} + +func GetAuditLogService() *AuditLogService { + return auditLogService +} + +func GetAuditLogController() *AuditLogController { + return auditLogController +} + +func SetupDependencies() { + users_services.GetUserService().SetAuditLogWriter(auditLogService) + users_services.GetSettingsService().SetAuditLogWriter(auditLogService) + users_services.GetManagementService().SetAuditLogWriter(auditLogService) +} diff --git a/backend/internal/features/audit_logs/dto.go b/backend/internal/features/audit_logs/dto.go new file mode 100644 index 0000000..6f2773e --- /dev/null +++ b/backend/internal/features/audit_logs/dto.go @@ -0,0 +1,31 @@ +package audit_logs + +import ( + "time" + + "github.com/google/uuid" +) + +type GetAuditLogsRequest struct { + Limit int `form:"limit" json:"limit"` + Offset int `form:"offset" json:"offset"` + BeforeDate *time.Time `form:"beforeDate" json:"beforeDate"` +} + +type GetAuditLogsResponse struct { + AuditLogs []*AuditLogDTO `json:"auditLogs"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type AuditLogDTO struct { + ID uuid.UUID `json:"id" gorm:"column:id"` + UserID *uuid.UUID `json:"userId" gorm:"column:user_id"` + WorkspaceID *uuid.UUID `json:"workspaceId" gorm:"column:workspace_id"` + Message string `json:"message" gorm:"column:message"` + CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` + UserEmail *string `json:"userEmail" gorm:"column:user_email"` + UserName *string `json:"userName" gorm:"column:user_name"` + WorkspaceName *string `json:"workspaceName" gorm:"column:workspace_name"` +} diff --git a/backend/internal/features/audit_logs/models.go b/backend/internal/features/audit_logs/models.go new file mode 100644 index 0000000..cbff174 --- /dev/null +++ b/backend/internal/features/audit_logs/models.go @@ -0,0 +1,19 @@ +package audit_logs + +import ( + "time" + + "github.com/google/uuid" +) + +type AuditLog struct { + ID uuid.UUID `json:"id" gorm:"column:id"` + UserID *uuid.UUID `json:"userId" gorm:"column:user_id"` + WorkspaceID *uuid.UUID `json:"workspaceId" gorm:"column:workspace_id"` + Message string `json:"message" gorm:"column:message"` + CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` +} + +func (AuditLog) TableName() string { + return "audit_logs" +} diff --git a/backend/internal/features/audit_logs/repository.go b/backend/internal/features/audit_logs/repository.go new file mode 100644 index 0000000..db8a7ed --- /dev/null +++ b/backend/internal/features/audit_logs/repository.go @@ -0,0 +1,139 @@ +package audit_logs + +import ( + "postgresus-backend/internal/storage" + "time" + + "github.com/google/uuid" +) + +type AuditLogRepository struct{} + +func (r *AuditLogRepository) Create(auditLog *AuditLog) error { + if auditLog.ID == uuid.Nil { + auditLog.ID = uuid.New() + } + + return storage.GetDb().Create(auditLog).Error +} + +func (r *AuditLogRepository) GetGlobal( + limit, offset int, + beforeDate *time.Time, +) ([]*AuditLogDTO, error) { + var auditLogs = make([]*AuditLogDTO, 0) + + sql := ` + SELECT + al.id, + al.user_id, + al.workspace_id, + al.message, + al.created_at, + u.email as user_email, + u.name as user_name, + w.name as workspace_name + FROM audit_logs al + LEFT JOIN users u ON al.user_id = u.id + LEFT JOIN workspaces w ON al.workspace_id = w.id` + + args := []interface{}{} + + if beforeDate != nil { + sql += " WHERE al.created_at < ?" + args = append(args, *beforeDate) + } + + sql += " ORDER BY al.created_at DESC LIMIT ? OFFSET ?" + args = append(args, limit, offset) + + err := storage.GetDb().Raw(sql, args...).Scan(&auditLogs).Error + + return auditLogs, err +} + +func (r *AuditLogRepository) GetByUser( + userID uuid.UUID, + limit, offset int, + beforeDate *time.Time, +) ([]*AuditLogDTO, error) { + var auditLogs = make([]*AuditLogDTO, 0) + + sql := ` + SELECT + al.id, + al.user_id, + al.workspace_id, + al.message, + al.created_at, + u.email as user_email, + u.name as user_name, + w.name as workspace_name + FROM audit_logs al + LEFT JOIN users u ON al.user_id = u.id + LEFT JOIN workspaces w ON al.workspace_id = w.id + WHERE al.user_id = ?` + + args := []interface{}{userID} + + if beforeDate != nil { + sql += " AND al.created_at < ?" + args = append(args, *beforeDate) + } + + sql += " ORDER BY al.created_at DESC LIMIT ? OFFSET ?" + args = append(args, limit, offset) + + err := storage.GetDb().Raw(sql, args...).Scan(&auditLogs).Error + + return auditLogs, err +} + +func (r *AuditLogRepository) GetByWorkspace( + workspaceID uuid.UUID, + limit, offset int, + beforeDate *time.Time, +) ([]*AuditLogDTO, error) { + var auditLogs = make([]*AuditLogDTO, 0) + + sql := ` + SELECT + al.id, + al.user_id, + al.workspace_id, + al.message, + al.created_at, + u.email as user_email, + u.name as user_name, + w.name as workspace_name + FROM audit_logs al + LEFT JOIN users u ON al.user_id = u.id + LEFT JOIN workspaces w ON al.workspace_id = w.id + WHERE al.workspace_id = ?` + + args := []interface{}{workspaceID} + + if beforeDate != nil { + sql += " AND al.created_at < ?" + args = append(args, *beforeDate) + } + + sql += " ORDER BY al.created_at DESC LIMIT ? OFFSET ?" + args = append(args, limit, offset) + + err := storage.GetDb().Raw(sql, args...).Scan(&auditLogs).Error + + return auditLogs, err +} + +func (r *AuditLogRepository) CountGlobal(beforeDate *time.Time) (int64, error) { + var count int64 + query := storage.GetDb().Model(&AuditLog{}) + + if beforeDate != nil { + query = query.Where("created_at < ?", *beforeDate) + } + + err := query.Count(&count).Error + return count, err +} diff --git a/backend/internal/features/audit_logs/service.go b/backend/internal/features/audit_logs/service.go new file mode 100644 index 0000000..2b7e8ac --- /dev/null +++ b/backend/internal/features/audit_logs/service.go @@ -0,0 +1,137 @@ +package audit_logs + +import ( + "errors" + "log/slog" + "time" + + user_enums "postgresus-backend/internal/features/users/enums" + user_models "postgresus-backend/internal/features/users/models" + + "github.com/google/uuid" +) + +type AuditLogService struct { + auditLogRepository *AuditLogRepository + logger *slog.Logger +} + +func (s *AuditLogService) WriteAuditLog( + message string, + userID *uuid.UUID, + workspaceID *uuid.UUID, +) { + auditLog := &AuditLog{ + UserID: userID, + WorkspaceID: workspaceID, + Message: message, + CreatedAt: time.Now().UTC(), + } + + err := s.auditLogRepository.Create(auditLog) + if err != nil { + s.logger.Error("failed to create audit log", "error", err) + return + } +} + +func (s *AuditLogService) CreateAuditLog(auditLog *AuditLog) error { + return s.auditLogRepository.Create(auditLog) +} + +func (s *AuditLogService) GetGlobalAuditLogs( + user *user_models.User, + request *GetAuditLogsRequest, +) (*GetAuditLogsResponse, error) { + if user.Role != user_enums.UserRoleAdmin { + return nil, errors.New("only administrators can view global audit logs") + } + + limit := request.Limit + if limit <= 0 || limit > 1000 { + limit = 100 + } + + offset := max(request.Offset, 0) + + auditLogs, err := s.auditLogRepository.GetGlobal(limit, offset, request.BeforeDate) + if err != nil { + return nil, err + } + + total, err := s.auditLogRepository.CountGlobal(request.BeforeDate) + if err != nil { + return nil, err + } + + return &GetAuditLogsResponse{ + AuditLogs: auditLogs, + Total: total, + Limit: limit, + Offset: offset, + }, nil +} + +func (s *AuditLogService) GetUserAuditLogs( + targetUserID uuid.UUID, + user *user_models.User, + request *GetAuditLogsRequest, +) (*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") + } + + limit := request.Limit + if limit <= 0 || limit > 1000 { + limit = 100 + } + + offset := max(request.Offset, 0) + + auditLogs, err := s.auditLogRepository.GetByUser( + targetUserID, + limit, + offset, + request.BeforeDate, + ) + if err != nil { + return nil, err + } + + return &GetAuditLogsResponse{ + AuditLogs: auditLogs, + Total: int64(len(auditLogs)), + Limit: limit, + Offset: offset, + }, nil +} + +func (s *AuditLogService) GetWorkspaceAuditLogs( + workspaceID uuid.UUID, + request *GetAuditLogsRequest, +) (*GetAuditLogsResponse, error) { + limit := request.Limit + if limit <= 0 || limit > 1000 { + limit = 100 + } + + offset := max(request.Offset, 0) + + auditLogs, err := s.auditLogRepository.GetByWorkspace( + workspaceID, + limit, + offset, + request.BeforeDate, + ) + if err != nil { + return nil, err + } + + return &GetAuditLogsResponse{ + AuditLogs: auditLogs, + Total: int64(len(auditLogs)), + Limit: limit, + Offset: offset, + }, nil +} diff --git a/backend/internal/features/audit_logs/service_test.go b/backend/internal/features/audit_logs/service_test.go new file mode 100644 index 0000000..a564def --- /dev/null +++ b/backend/internal/features/audit_logs/service_test.go @@ -0,0 +1,83 @@ +package audit_logs + +import ( + "testing" + "time" + + user_enums "postgresus-backend/internal/features/users/enums" + users_testing "postgresus-backend/internal/features/users/testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func Test_AuditLogs_WorkspaceSpecificLogs(t *testing.T) { + service := GetAuditLogService() + user1 := users_testing.CreateTestUser(user_enums.UserRoleMember) + user2 := users_testing.CreateTestUser(user_enums.UserRoleMember) + workspace1ID, workspace2ID := uuid.New(), uuid.New() + + // Create test logs for workspaces + createAuditLog(service, "Test workspace1 log first", &user1.UserID, &workspace1ID) + createAuditLog(service, "Test workspace1 log second", &user2.UserID, &workspace1ID) + createAuditLog(service, "Test workspace2 log first", &user1.UserID, &workspace2ID) + createAuditLog(service, "Test workspace2 log second", &user2.UserID, &workspace2ID) + createAuditLog(service, "Test no workspace log", &user1.UserID, nil) + + request := &GetAuditLogsRequest{Limit: 10, Offset: 0} + + // Test workspace 1 logs + workspace1Response, err := service.GetWorkspaceAuditLogs(workspace1ID, request) + assert.NoError(t, err) + assert.Equal(t, 2, len(workspace1Response.AuditLogs)) + + messages := extractMessages(workspace1Response.AuditLogs) + assert.Contains(t, messages, "Test workspace1 log first") + assert.Contains(t, messages, "Test workspace1 log second") + for _, log := range workspace1Response.AuditLogs { + assert.Equal(t, &workspace1ID, log.WorkspaceID) + } + + // Test workspace 2 logs + workspace2Response, err := service.GetWorkspaceAuditLogs(workspace2ID, request) + assert.NoError(t, err) + assert.Equal(t, 2, len(workspace2Response.AuditLogs)) + + messages2 := extractMessages(workspace2Response.AuditLogs) + assert.Contains(t, messages2, "Test workspace2 log first") + assert.Contains(t, messages2, "Test workspace2 log second") + + // Test pagination + limitedResponse, err := service.GetWorkspaceAuditLogs(workspace1ID, + &GetAuditLogsRequest{Limit: 1, Offset: 0}) + assert.NoError(t, err) + assert.Equal(t, 1, len(limitedResponse.AuditLogs)) + assert.Equal(t, 1, limitedResponse.Limit) + + // Test beforeDate filter + beforeTime := time.Now().UTC().Add(-1 * time.Minute) + filteredResponse, err := service.GetWorkspaceAuditLogs(workspace1ID, + &GetAuditLogsRequest{Limit: 10, BeforeDate: &beforeTime}) + assert.NoError(t, err) + for _, log := range filteredResponse.AuditLogs { + assert.True(t, log.CreatedAt.Before(beforeTime)) + assert.NotNil(t, log.UserEmail, "User email should be present for logs with user_id") + assert.NotNil( + t, + log.WorkspaceName, + "Workspace name should be present for logs with workspace_id", + ) + } +} + +func createAuditLog(service *AuditLogService, message string, userID, workspaceID *uuid.UUID) { + service.WriteAuditLog(message, userID, workspaceID) +} + +func extractMessages(logs []*AuditLogDTO) []string { + messages := make([]string, len(logs)) + for i, log := range logs { + messages[i] = log.Message + } + return messages +} diff --git a/backend/internal/features/backups/backups/background_service_test.go b/backend/internal/features/backups/backups/background_service_test.go index 9375471..15c10bd 100644 --- a/backend/internal/features/backups/backups/background_service_test.go +++ b/backend/internal/features/backups/backups/background_service_test.go @@ -6,7 +6,9 @@ import ( "postgresus-backend/internal/features/intervals" "postgresus-backend/internal/features/notifiers" "postgresus-backend/internal/features/storages" - "postgresus-backend/internal/features/users" + users_enums "postgresus-backend/internal/features/users/enums" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" "postgresus-backend/internal/util/period" "testing" "time" @@ -16,10 +18,12 @@ import ( func Test_MakeBackupForDbHavingBackupDayAgo_BackupCreated(t *testing.T) { // setup data - user := users.GetTestUser() - storage := storages.CreateTestStorage(user.UserID) - notifier := notifiers.CreateTestNotifier(user.UserID) - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + 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) // Enable backups for the database backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID) @@ -67,16 +71,20 @@ func Test_MakeBackupForDbHavingBackupDayAgo_BackupCreated(t *testing.T) { } databases.RemoveTestDatabase(database) - storages.RemoveTestStorage(storage.ID) + 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) { // setup data - user := users.GetTestUser() - storage := storages.CreateTestStorage(user.UserID) - notifier := notifiers.CreateTestNotifier(user.UserID) - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + 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) // Enable backups for the database backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID) @@ -124,16 +132,20 @@ func Test_MakeBackupForDbHavingHourAgoBackup_BackupSkipped(t *testing.T) { } databases.RemoveTestDatabase(database) - storages.RemoveTestStorage(storage.ID) + 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) { // setup data - user := users.GetTestUser() - storage := storages.CreateTestStorage(user.UserID) - notifier := notifiers.CreateTestNotifier(user.UserID) - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + 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) // Enable backups for the database with retries disabled backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID) @@ -185,16 +197,20 @@ func Test_MakeBackupHavingFailedBackupWithoutRetries_BackupSkipped(t *testing.T) } databases.RemoveTestDatabase(database) - storages.RemoveTestStorage(storage.ID) + 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) { // setup data - user := users.GetTestUser() - storage := storages.CreateTestStorage(user.UserID) - notifier := notifiers.CreateTestNotifier(user.UserID) - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + 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) // Enable backups for the database with retries enabled backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID) @@ -246,16 +262,20 @@ func Test_MakeBackupHavingFailedBackupWithRetries_BackupCreated(t *testing.T) { } databases.RemoveTestDatabase(database) - storages.RemoveTestStorage(storage.ID) + time.Sleep(50 * 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) { // setup data - user := users.GetTestUser() - storage := storages.CreateTestStorage(user.UserID) - notifier := notifiers.CreateTestNotifier(user.UserID) - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + 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) // Enable backups for the database with retries enabled backupConfig, err := backups_config.GetBackupConfigService().GetBackupConfigByDbId(database.ID) @@ -309,6 +329,8 @@ func Test_MakeBackupHavingFailedBackupWithRetries_RetriesCountNotExceeded(t *tes } databases.RemoveTestDatabase(database) - storages.RemoveTestStorage(storage.ID) + time.Sleep(50 * time.Millisecond) // Wait for cascading deletes notifiers.RemoveTestNotifier(notifier) + storages.RemoveTestStorage(storage.ID) + workspaces_testing.RemoveTestWorkspace(workspace, router) } diff --git a/backend/internal/features/backups/backups/controller.go b/backend/internal/features/backups/backups/controller.go index f874e3e..20f09fb 100644 --- a/backend/internal/features/backups/backups/controller.go +++ b/backend/internal/features/backups/backups/controller.go @@ -4,7 +4,7 @@ import ( "fmt" "io" "net/http" - "postgresus-backend/internal/features/users" + users_middleware "postgresus-backend/internal/features/users/middleware" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -12,7 +12,6 @@ import ( type BackupController struct { backupService *BackupService - userService *users.UserService } func (c *BackupController) RegisterRoutes(router *gin.RouterGroup) { @@ -34,6 +33,12 @@ func (c *BackupController) RegisterRoutes(router *gin.RouterGroup) { // @Failure 500 // @Router /backups [get] func (c *BackupController) GetBackups(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + databaseIDStr := ctx.Query("database_id") if databaseIDStr == "" { ctx.JSON(http.StatusBadRequest, gin.H{"error": "database_id query parameter is required"}) @@ -46,18 +51,6 @@ func (c *BackupController) GetBackups(ctx *gin.Context) { return } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - backups, err := c.backupService.GetBackups(user, databaseID) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -80,24 +73,18 @@ func (c *BackupController) GetBackups(ctx *gin.Context) { // @Failure 500 // @Router /backups [post] func (c *BackupController) MakeBackup(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + var request MakeBackupRequest if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - if err := c.backupService.MakeBackupWithAuth(user, request.DatabaseID); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -117,24 +104,18 @@ func (c *BackupController) MakeBackup(ctx *gin.Context) { // @Failure 500 // @Router /backups/{id} [delete] func (c *BackupController) DeleteBackup(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 backup ID"}) return } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - if err := c.backupService.DeleteBackup(user, id); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -154,24 +135,18 @@ func (c *BackupController) DeleteBackup(ctx *gin.Context) { // @Failure 500 // @Router /backups/{id}/file [get] func (c *BackupController) GetFile(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 backup ID"}) return } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - fileReader, err := c.backupService.GetBackupFile(user, id) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -179,19 +154,16 @@ func (c *BackupController) GetFile(ctx *gin.Context) { } defer func() { if err := fileReader.Close(); err != nil { - // Log the error but don't interrupt the response fmt.Printf("Error closing file reader: %v\n", err) } }() - // Set headers for file download ctx.Header("Content-Type", "application/octet-stream") ctx.Header( "Content-Disposition", fmt.Sprintf("attachment; filename=\"backup_%s.dump\"", id.String()), ) - // Stream the file content _, err = io.Copy(ctx.Writer, fileReader) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to stream file"}) diff --git a/backend/internal/features/backups/backups/controller_test.go b/backend/internal/features/backups/backups/controller_test.go new file mode 100644 index 0000000..d16d49a --- /dev/null +++ b/backend/internal/features/backups/backups/controller_test.go @@ -0,0 +1,640 @@ +package backups + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + audit_logs "postgresus-backend/internal/features/audit_logs" + backups_config "postgresus-backend/internal/features/backups/config" + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/databases/databases/postgresql" + "postgresus-backend/internal/features/storages" + local_storage "postgresus-backend/internal/features/storages/models/local" + users_dto "postgresus-backend/internal/features/users/dto" + users_enums "postgresus-backend/internal/features/users/enums" + users_services "postgresus-backend/internal/features/users/services" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_models "postgresus-backend/internal/features/workspaces/models" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + "postgresus-backend/internal/util/tools" +) + +func Test_GetBackups_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace viewer can get backups", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can get backups", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-member cannot get backups", + workspaceRole: nil, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can get backups", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database, _ := createTestDatabaseWithBackups(workspace, owner, router) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.workspaceRole != nil { + if *tt.workspaceRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUserToken = nonMember.Token + } + + testResp := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/backups?database_id=%s", database.ID.String()), + "Bearer "+testUserToken, + tt.expectedStatusCode, + ) + + if tt.expectSuccess { + var backups []*Backup + err := json.Unmarshal(testResp.Body, &backups) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(backups), 1) + } else { + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_CreateBackup_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can create backup", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can create backup", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace viewer can create backup", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-member cannot create backup", + workspaceRole: nil, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can create backup", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabase("Test Database", workspace.ID, owner.Token, router) + enableBackupForDatabase(database.ID) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.workspaceRole != nil { + if *tt.workspaceRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUserToken = nonMember.Token + } + + request := MakeBackupRequest{DatabaseID: database.ID} + testResp := test_utils.MakePostRequest( + t, + router, + "/api/v1/backups", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + ) + + if tt.expectSuccess { + assert.Contains(t, string(testResp.Body), "backup started successfully") + } else { + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_CreateBackup_AuditLogWritten(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabase("Test Database", workspace.ID, owner.Token, router) + enableBackupForDatabase(database.ID) + + request := MakeBackupRequest{DatabaseID: database.ID} + test_utils.MakePostRequest( + t, + router, + "/api/v1/backups", + "Bearer "+owner.Token, + request, + http.StatusOK, + ) + + time.Sleep(100 * time.Millisecond) + + auditLogService := audit_logs.GetAuditLogService() + auditLogs, err := auditLogService.GetWorkspaceAuditLogs( + workspace.ID, + &audit_logs.GetAuditLogsRequest{ + Limit: 100, + Offset: 0, + }, + ) + assert.NoError(t, err) + + found := false + for _, log := range auditLogs.AuditLogs { + if strings.Contains(log.Message, "Backup manually initiated") && + strings.Contains(log.Message, database.Name) { + found = true + break + } + } + assert.True(t, found, "Audit log for backup creation not found") +} + +func Test_DeleteBackup_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can delete backup", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "workspace member can delete backup", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "workspace viewer cannot delete backup", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "non-member cannot delete backup", + workspaceRole: nil, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can delete backup", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusNoContent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database, backup := createTestDatabaseWithBackups(workspace, owner, router) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.workspaceRole != nil { + if *tt.workspaceRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUserToken = nonMember.Token + } + + testResp := test_utils.MakeDeleteRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s", backup.ID.String()), + "Bearer "+testUserToken, + tt.expectedStatusCode, + ) + + if !tt.expectSuccess { + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } else { + userService := users_services.GetUserService() + ownerUser, err := userService.GetUserFromToken(owner.Token) + assert.NoError(t, err) + + backups, err := GetBackupService().GetBackups(ownerUser, database.ID) + assert.NoError(t, err) + assert.Equal(t, 0, len(backups)) + } + }) + } +} + +func Test_DeleteBackup_AuditLogWritten(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database, backup := createTestDatabaseWithBackups(workspace, owner, router) + + test_utils.MakeDeleteRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s", backup.ID.String()), + "Bearer "+owner.Token, + http.StatusNoContent, + ) + + time.Sleep(100 * time.Millisecond) + + auditLogService := audit_logs.GetAuditLogService() + auditLogs, err := auditLogService.GetWorkspaceAuditLogs( + workspace.ID, + &audit_logs.GetAuditLogsRequest{ + Limit: 100, + Offset: 0, + }, + ) + assert.NoError(t, err) + + found := false + for _, log := range auditLogs.AuditLogs { + if strings.Contains(log.Message, "Backup deleted") && + strings.Contains(log.Message, database.Name) { + found = true + break + } + } + assert.True(t, found, "Audit log for backup deletion not found") +} + +func Test_DownloadBackup_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace viewer can download backup", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can download backup", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-member cannot download backup", + workspaceRole: nil, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can download backup", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + _, backup := createTestDatabaseWithBackups(workspace, owner, router) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.workspaceRole != nil { + if *tt.workspaceRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUserToken = nonMember.Token + } + + testResp := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/file", backup.ID.String()), + "Bearer "+testUserToken, + tt.expectedStatusCode, + ) + + if !tt.expectSuccess { + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_DownloadBackup_AuditLogWritten(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database, backup := createTestDatabaseWithBackups(workspace, owner, router) + + test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/file", backup.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + ) + + time.Sleep(100 * time.Millisecond) + + auditLogService := audit_logs.GetAuditLogService() + auditLogs, err := auditLogService.GetWorkspaceAuditLogs( + workspace.ID, + &audit_logs.GetAuditLogsRequest{ + Limit: 100, + Offset: 0, + }, + ) + assert.NoError(t, err) + + found := false + for _, log := range auditLogs.AuditLogs { + if strings.Contains(log.Message, "Backup file downloaded") && + strings.Contains(log.Message, database.Name) { + found = true + break + } + } + assert.True(t, found, "Audit log for backup download not found") +} + +func createTestRouter() *gin.Engine { + return CreateTestRouter() +} + +func createTestDatabase( + name string, + workspaceID uuid.UUID, + token string, + router *gin.Engine, +) *databases.Database { + testDbName := "test_db" + request := databases.Database{ + Name: name, + WorkspaceID: &workspaceID, + Type: databases.DatabaseTypePostgres, + Postgresql: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + Database: &testDbName, + }, + } + + w := workspaces_testing.MakeAPIRequest( + router, + "POST", + "/api/v1/databases/create", + "Bearer "+token, + request, + ) + + if w.Code != http.StatusCreated { + panic( + fmt.Sprintf("Failed to create database. Status: %d, Body: %s", w.Code, w.Body.String()), + ) + } + + var database databases.Database + if err := json.Unmarshal(w.Body.Bytes(), &database); err != nil { + panic(err) + } + + return &database +} + +func createTestStorage(workspaceID uuid.UUID) *storages.Storage { + storage := &storages.Storage{ + WorkspaceID: workspaceID, + Type: storages.StorageTypeLocal, + Name: "Test Storage " + uuid.New().String(), + LocalStorage: &local_storage.LocalStorage{}, + } + + repo := &storages.StorageRepository{} + storage, err := repo.Save(storage) + if err != nil { + panic(err) + } + + return storage +} + +func enableBackupForDatabase(databaseID uuid.UUID) { + configService := backups_config.GetBackupConfigService() + config, err := configService.GetBackupConfigByDbId(databaseID) + if err != nil { + panic(err) + } + + config.IsBackupsEnabled = true + _, err = configService.SaveBackupConfig(config) + if err != nil { + panic(err) + } +} + +func createTestDatabaseWithBackups( + workspace *workspaces_models.Workspace, + owner *users_dto.SignInResponseDTO, + router *gin.Engine, +) (*databases.Database, *Backup) { + database := createTestDatabase("Test Database", workspace.ID, owner.Token, router) + storage := createTestStorage(workspace.ID) + + configService := backups_config.GetBackupConfigService() + config, err := configService.GetBackupConfigByDbId(database.ID) + if err != nil { + panic(err) + } + + config.IsBackupsEnabled = true + config.StorageID = &storage.ID + config.Storage = storage + _, err = configService.SaveBackupConfig(config) + if err != nil { + panic(err) + } + + backup := createTestBackup(database, owner) + + return database, backup +} + +func createTestBackup( + database *databases.Database, + owner *users_dto.SignInResponseDTO, +) *Backup { + userService := users_services.GetUserService() + user, err := userService.GetUserFromToken(owner.Token) + if err != nil { + panic(err) + } + + storages, err := storages.GetStorageService().GetStorages(user, *database.WorkspaceID) + if err != nil || len(storages) == 0 { + panic("No storage found for workspace") + } + + backup := &Backup{ + ID: uuid.New(), + DatabaseID: database.ID, + Database: database, + StorageID: storages[0].ID, + Storage: storages[0], + Status: BackupStatusCompleted, + BackupSizeMb: 10.5, + BackupDurationMs: 1000, + CreatedAt: time.Now().UTC(), + } + + repo := &BackupRepository{} + if err := repo.Save(backup); err != nil { + panic(err) + } + + // Create a dummy backup file for testing download functionality + dummyContent := []byte("dummy backup content for testing") + reader := strings.NewReader(string(dummyContent)) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if err := storages[0].SaveFile(logger, backup.ID, reader); err != nil { + panic(fmt.Sprintf("Failed to create test backup file: %v", err)) + } + + return backup +} diff --git a/backend/internal/features/backups/backups/di.go b/backend/internal/features/backups/backups/di.go index 27070ca..29dd0ae 100644 --- a/backend/internal/features/backups/backups/di.go +++ b/backend/internal/features/backups/backups/di.go @@ -1,12 +1,13 @@ package backups import ( + audit_logs "postgresus-backend/internal/features/audit_logs" "postgresus-backend/internal/features/backups/backups/usecases" backups_config "postgresus-backend/internal/features/backups/config" "postgresus-backend/internal/features/databases" "postgresus-backend/internal/features/notifiers" "postgresus-backend/internal/features/storages" - "postgresus-backend/internal/features/users" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "postgresus-backend/internal/util/logger" "time" ) @@ -22,6 +23,8 @@ var backupService = &BackupService{ usecases.GetCreateBackupUsecase(), logger.GetLogger(), []BackupRemoveListener{}, + workspaces_services.GetWorkspaceService(), + audit_logs.GetAuditLogService(), } var backupBackgroundService = &BackupBackgroundService{ @@ -35,7 +38,6 @@ var backupBackgroundService = &BackupBackgroundService{ var backupController = &BackupController{ backupService, - users.GetUserService(), } func SetupDependencies() { diff --git a/backend/internal/features/backups/backups/service.go b/backend/internal/features/backups/backups/service.go index 5966a73..c7f59c5 100644 --- a/backend/internal/features/backups/backups/service.go +++ b/backend/internal/features/backups/backups/service.go @@ -5,11 +5,13 @@ import ( "fmt" "io" "log/slog" + audit_logs "postgresus-backend/internal/features/audit_logs" backups_config "postgresus-backend/internal/features/backups/config" "postgresus-backend/internal/features/databases" "postgresus-backend/internal/features/notifiers" "postgresus-backend/internal/features/storages" users_models "postgresus-backend/internal/features/users/models" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "slices" "time" @@ -29,6 +31,9 @@ type BackupService struct { logger *slog.Logger backupRemoveListeners []BackupRemoveListener + + workspaceService *workspaces_services.WorkspaceService + auditLogService *audit_logs.AuditLogService } func (s *BackupService) AddBackupRemoveListener(listener BackupRemoveListener) { @@ -62,12 +67,26 @@ func (s *BackupService) MakeBackupWithAuth( return err } - if database.UserID != user.ID { - return errors.New("user does not have access to this database") + if database.WorkspaceID == nil { + return errors.New("cannot create backup for database without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user) + if err != nil { + return err + } + if !canAccess { + return errors.New("insufficient permissions to create backup for this database") } go s.MakeBackup(databaseID, true) + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Backup manually initiated for database: %s", database.Name), + &user.ID, + database.WorkspaceID, + ) + return nil } @@ -80,8 +99,16 @@ func (s *BackupService) GetBackups( return nil, err } - if database.UserID != user.ID { - return nil, errors.New("user does not have access to this database") + if database.WorkspaceID == nil { + return nil, errors.New("cannot get backups for database without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user) + if err != nil { + return nil, err + } + if !canAccess { + return nil, errors.New("insufficient permissions to access backups for this database") } backups, err := s.backupRepository.FindByDatabaseID(databaseID) @@ -101,14 +128,32 @@ func (s *BackupService) DeleteBackup( return err } - if backup.Database.UserID != user.ID { - return errors.New("user does not have access to this backup") + if backup.Database.WorkspaceID == nil { + return errors.New("cannot delete backup for database without workspace") + } + + canManage, err := s.workspaceService.CanUserManageDBs(*backup.Database.WorkspaceID, user) + if err != nil { + return err + } + if !canManage { + return errors.New("insufficient permissions to delete backup for this database") } if backup.Status == BackupStatusInProgress { return errors.New("backup is in progress") } + s.auditLogService.WriteAuditLog( + fmt.Sprintf( + "Backup deleted for database: %s (ID: %s)", + backup.Database.Name, + backupID.String(), + ), + &user.ID, + backup.Database.WorkspaceID, + ) + return s.deleteBackup(backup) } @@ -328,8 +373,19 @@ func (s *BackupService) GetBackupFile( return nil, err } - if backup.Database.UserID != user.ID { - return nil, errors.New("user does not have access to this backup") + if backup.Database.WorkspaceID == nil { + return nil, errors.New("cannot download backup for database without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace( + *backup.Database.WorkspaceID, + user, + ) + if err != nil { + return nil, err + } + if !canAccess { + return nil, errors.New("insufficient permissions to download backup for this database") } storage, err := s.storageService.GetStorageByID(backup.StorageID) @@ -337,6 +393,16 @@ func (s *BackupService) GetBackupFile( return nil, err } + s.auditLogService.WriteAuditLog( + fmt.Sprintf( + "Backup file downloaded for database: %s (ID: %s)", + backup.Database.Name, + backupID.String(), + ), + &user.ID, + backup.Database.WorkspaceID, + ) + return storage.GetFile(backup.ID) } diff --git a/backend/internal/features/backups/backups/service_test.go b/backend/internal/features/backups/backups/service_test.go index d08ce7d..02f0cff 100644 --- a/backend/internal/features/backups/backups/service_test.go +++ b/backend/internal/features/backups/backups/service_test.go @@ -6,10 +6,13 @@ import ( "postgresus-backend/internal/features/databases" "postgresus-backend/internal/features/notifiers" "postgresus-backend/internal/features/storages" - "postgresus-backend/internal/features/users" + users_enums "postgresus-backend/internal/features/users/enums" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" "postgresus-backend/internal/util/logger" "strings" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -17,15 +20,27 @@ import ( ) func Test_BackupExecuted_NotificationSent(t *testing.T) { - user := users.GetTestUser() - storage := storages.CreateTestStorage(user.UserID) - notifier := notifiers.CreateTestNotifier(user.UserID) - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + 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) backups_config.EnableBackupsForTestDatabase(database.ID, storage) - defer storages.RemoveTestStorage(storage.ID) - defer notifiers.RemoveTestNotifier(notifier) - defer databases.RemoveTestDatabase(database) + 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) // Wait for cascading deletes + notifiers.RemoveTestNotifier(notifier) + storages.RemoveTestStorage(storage.ID) + workspaces_testing.RemoveTestWorkspace(workspace, router) + }() t.Run("BackupFailed_FailNotificationSent", func(t *testing.T) { mockNotificationSender := &MockNotificationSender{} @@ -39,6 +54,8 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) { &CreateFailedBackupUsecase{}, logger.GetLogger(), []BackupRemoveListener{}, + nil, // workspaceService + nil, // auditLogService } // Set up expectations @@ -82,6 +99,8 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) { &CreateSuccessBackupUsecase{}, logger.GetLogger(), []BackupRemoveListener{}, + nil, // workspaceService + nil, // auditLogService } backupService.MakeBackup(database.ID, true) @@ -102,6 +121,8 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) { &CreateSuccessBackupUsecase{}, logger.GetLogger(), []BackupRemoveListener{}, + nil, // workspaceService + nil, // auditLogService } // capture arguments diff --git a/backend/internal/features/backups/backups/testing.go b/backend/internal/features/backups/backups/testing.go new file mode 100644 index 0000000..d794f3a --- /dev/null +++ b/backend/internal/features/backups/backups/testing.go @@ -0,0 +1,20 @@ +package backups + +import ( + backups_config "postgresus-backend/internal/features/backups/config" + "postgresus-backend/internal/features/databases" + workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + + "github.com/gin-gonic/gin" +) + +func CreateTestRouter() *gin.Engine { + return workspaces_testing.CreateTestRouter( + workspaces_controllers.GetWorkspaceController(), + workspaces_controllers.GetMembershipController(), + databases.GetDatabaseController(), + backups_config.GetBackupConfigController(), + GetBackupController(), + ) +} diff --git a/backend/internal/features/backups/config/controller.go b/backend/internal/features/backups/config/controller.go index a087c3c..17fc533 100644 --- a/backend/internal/features/backups/config/controller.go +++ b/backend/internal/features/backups/config/controller.go @@ -2,7 +2,7 @@ package backups_config import ( "net/http" - "postgresus-backend/internal/features/users" + users_middleware "postgresus-backend/internal/features/users/middleware" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -10,7 +10,6 @@ import ( type BackupConfigController struct { backupConfigService *BackupConfigService - userService *users.UserService } func (c *BackupConfigController) RegisterRoutes(router *gin.RouterGroup) { @@ -32,24 +31,18 @@ func (c *BackupConfigController) RegisterRoutes(router *gin.RouterGroup) { // @Failure 500 // @Router /backup-configs/save [post] func (c *BackupConfigController) SaveBackupConfig(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + var requestDTO BackupConfig if err := ctx.ShouldBindJSON(&requestDTO); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - // make sure we rely on full .Storage object requestDTO.StorageID = nil @@ -74,30 +67,18 @@ func (c *BackupConfigController) SaveBackupConfig(ctx *gin.Context) { // @Failure 404 // @Router /backup-configs/database/{id} [get] func (c *BackupConfigController) GetBackupConfigByDbID(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 } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - _, err = c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - backupConfig, err := c.backupConfigService.GetBackupConfigByDbIdWithAuth(user, id) if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "backup configuration not found"}) @@ -113,31 +94,38 @@ func (c *BackupConfigController) GetBackupConfigByDbID(ctx *gin.Context) { // @Tags backup-configs // @Produce json // @Param id path string true "Storage ID" +// @Param workspace_id query string true "Workspace ID" // @Success 200 {object} map[string]bool // @Failure 400 // @Failure 401 // @Failure 500 // @Router /backup-configs/storage/{id}/is-using [get] func (c *BackupConfigController) IsStorageUsing(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 } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + workspaceIDStr := ctx.Query("workspace_id") + if workspaceIDStr == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter is required"}) return } - user, err := c.userService.GetUserFromToken(authorizationHeader) + workspaceID, err := uuid.Parse(workspaceIDStr) if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace_id"}) return } - isUsing, err := c.backupConfigService.IsStorageUsing(user, id) + isUsing, err := c.backupConfigService.IsStorageUsing(user, workspaceID, id) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/backend/internal/features/backups/config/controller_test.go b/backend/internal/features/backups/config/controller_test.go new file mode 100644 index 0000000..aca4176 --- /dev/null +++ b/backend/internal/features/backups/config/controller_test.go @@ -0,0 +1,413 @@ +package backups_config + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/databases/databases/postgresql" + "postgresus-backend/internal/features/intervals" + "postgresus-backend/internal/features/storages" + users_enums "postgresus-backend/internal/features/users/enums" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + "postgresus-backend/internal/util/period" + test_utils "postgresus-backend/internal/util/testing" + "postgresus-backend/internal/util/tools" +) + +func createTestRouter() *gin.Engine { + router := workspaces_testing.CreateTestRouter( + workspaces_controllers.GetWorkspaceController(), + workspaces_controllers.GetMembershipController(), + databases.GetDatabaseController(), + GetBackupConfigController(), + ) + return router +} + +func Test_SaveBackupConfig_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can save backup config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can save backup config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can save backup config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace viewer cannot save backup config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can save backup config", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + + timeOfDay := "04:00" + request := BackupConfig{ + DatabaseID: database.ID, + IsBackupsEnabled: true, + StorePeriod: period.PeriodWeek, + BackupInterval: &intervals.Interval{ + Interval: intervals.IntervalDaily, + TimeOfDay: &timeOfDay, + }, + SendNotificationsOn: []BackupNotificationType{ + NotificationBackupFailed, + }, + CpuCount: 2, + IsRetryIfFailed: true, + MaxFailedTriesCount: 3, + } + + var response BackupConfig + testResp := test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/backup-configs/save", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + &response, + ) + + if tt.expectSuccess { + assert.Equal(t, database.ID, response.DatabaseID) + assert.True(t, response.IsBackupsEnabled) + assert.Equal(t, period.PeriodWeek, response.StorePeriod) + assert.Equal(t, 2, response.CpuCount) + } else { + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_SaveBackupConfig_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + + timeOfDay := "04:00" + request := BackupConfig{ + DatabaseID: database.ID, + IsBackupsEnabled: true, + StorePeriod: period.PeriodWeek, + BackupInterval: &intervals.Interval{ + Interval: intervals.IntervalDaily, + TimeOfDay: &timeOfDay, + }, + SendNotificationsOn: []BackupNotificationType{ + NotificationBackupFailed, + }, + CpuCount: 2, + IsRetryIfFailed: true, + MaxFailedTriesCount: 3, + } + + testResp := test_utils.MakePostRequest( + t, + router, + "/api/v1/backup-configs/save", + "Bearer "+nonMember.Token, + request, + http.StatusBadRequest, + ) + + assert.Contains(t, string(testResp.Body), "insufficient permissions") +} + +func Test_GetBackupConfigByDbID_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can get backup config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can get backup config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can get backup config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace viewer can get backup config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can get backup config", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-member cannot get backup config", + workspaceRole: nil, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUserToken = nonMember.Token + } + + var response BackupConfig + testResp := test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/backup-configs/database/"+database.ID.String(), + "Bearer "+testUserToken, + tt.expectedStatusCode, + &response, + ) + + if tt.expectSuccess { + assert.Equal(t, database.ID, response.DatabaseID) + assert.NotNil(t, response.BackupInterval) + } else { + assert.Contains(t, string(testResp.Body), "backup configuration not found") + } + }) + } +} + +func Test_GetBackupConfigByDbID_ReturnsDefaultConfigForNewDatabase(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var response BackupConfig + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/backup-configs/database/"+database.ID.String(), + "Bearer "+owner.Token, + http.StatusOK, + &response, + ) + + assert.Equal(t, database.ID, response.DatabaseID) + assert.False(t, response.IsBackupsEnabled) + assert.Equal(t, period.PeriodWeek, response.StorePeriod) + assert.Equal(t, 1, response.CpuCount) + assert.True(t, response.IsRetryIfFailed) + assert.Equal(t, 3, response.MaxFailedTriesCount) + assert.NotNil(t, response.BackupInterval) +} + +func Test_IsStorageUsing_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + isStorageOwner bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "storage owner can check storage usage", + isStorageOwner: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-storage-owner cannot check storage usage", + isStorageOwner: false, + expectSuccess: false, + expectedStatusCode: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + storageOwner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace( + "Test Workspace", + storageOwner, + router, + ) + storage := createTestStorage(workspace.ID) + + var testUserToken string + if tt.isStorageOwner { + testUserToken = storageOwner.Token + } else { + otherUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUserToken = otherUser.Token + } + + if tt.expectSuccess { + var response map[string]bool + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/backup-configs/storage/"+storage.ID.String()+"/is-using?workspace_id="+workspace.ID.String(), + "Bearer "+testUserToken, + tt.expectedStatusCode, + &response, + ) + + isUsing, exists := response["isUsing"] + assert.True(t, exists) + assert.False(t, isUsing) + } else { + testResp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/backup-configs/storage/"+storage.ID.String()+"/is-using?workspace_id="+workspace.ID.String(), + "Bearer "+testUserToken, + tt.expectedStatusCode, + ) + assert.Contains(t, string(testResp.Body), "error") + } + + // Cleanup + storages.RemoveTestStorage(storage.ID) + workspaces_testing.RemoveTestWorkspace(workspace, router) + }) + } +} + +func createTestDatabaseViaAPI( + name string, + workspaceID uuid.UUID, + token string, + router *gin.Engine, +) *databases.Database { + testDbName := "test_db" + request := databases.Database{ + WorkspaceID: &workspaceID, + Name: name, + Type: databases.DatabaseTypePostgres, + Postgresql: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + Database: &testDbName, + }, + } + + w := workspaces_testing.MakeAPIRequest( + router, + "POST", + "/api/v1/databases/create", + "Bearer "+token, + request, + ) + + if w.Code != http.StatusCreated { + panic("Failed to create database") + } + + var database databases.Database + if err := json.Unmarshal(w.Body.Bytes(), &database); err != nil { + panic(err) + } + return &database +} + +func createTestStorage(workspaceID uuid.UUID) *storages.Storage { + return storages.CreateTestStorage(workspaceID) +} diff --git a/backend/internal/features/backups/config/di.go b/backend/internal/features/backups/config/di.go index 575126c..2063eab 100644 --- a/backend/internal/features/backups/config/di.go +++ b/backend/internal/features/backups/config/di.go @@ -3,7 +3,7 @@ package backups_config import ( "postgresus-backend/internal/features/databases" "postgresus-backend/internal/features/storages" - "postgresus-backend/internal/features/users" + workspaces_services "postgresus-backend/internal/features/workspaces/services" ) var backupConfigRepository = &BackupConfigRepository{} @@ -11,11 +11,11 @@ var backupConfigService = &BackupConfigService{ backupConfigRepository, databases.GetDatabaseService(), storages.GetStorageService(), + workspaces_services.GetWorkspaceService(), nil, } var backupConfigController = &BackupConfigController{ backupConfigService, - users.GetUserService(), } func GetBackupConfigController() *BackupConfigController { diff --git a/backend/internal/features/backups/config/service.go b/backend/internal/features/backups/config/service.go index 71197e4..458d0a1 100644 --- a/backend/internal/features/backups/config/service.go +++ b/backend/internal/features/backups/config/service.go @@ -1,10 +1,13 @@ package backups_config import ( + "errors" + "postgresus-backend/internal/features/databases" "postgresus-backend/internal/features/intervals" "postgresus-backend/internal/features/storages" users_models "postgresus-backend/internal/features/users/models" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "postgresus-backend/internal/util/period" "github.com/google/uuid" @@ -14,6 +17,7 @@ type BackupConfigService struct { backupConfigRepository *BackupConfigRepository databaseService *databases.DatabaseService storageService *storages.StorageService + workspaceService *workspaces_services.WorkspaceService dbStorageChangeListener BackupConfigStorageChangeListener } @@ -32,11 +36,23 @@ func (s *BackupConfigService) SaveBackupConfigWithAuth( return nil, err } - _, err := s.databaseService.GetDatabase(user, backupConfig.DatabaseID) + database, err := s.databaseService.GetDatabase(user, backupConfig.DatabaseID) if err != nil { return nil, err } + if database.WorkspaceID == nil { + return nil, errors.New("cannot save backup config for database without workspace") + } + + canManage, err := s.workspaceService.CanUserManageDBs(*database.WorkspaceID, user) + if err != nil { + return nil, err + } + if !canManage { + return nil, errors.New("insufficient permissions to modify backup configuration") + } + return s.SaveBackupConfig(backupConfig) } @@ -116,6 +132,7 @@ func (s *BackupConfigService) GetBackupConfigByDbId( func (s *BackupConfigService) IsStorageUsing( user *users_models.User, + workspaceID uuid.UUID, storageID uuid.UUID, ) (bool, error) { _, err := s.storageService.GetStorage(user, storageID) @@ -144,6 +161,10 @@ func (s *BackupConfigService) OnDatabaseCopied(originalDatabaseID, newDatabaseID } } +func (s *BackupConfigService) CreateDisabledBackupConfig(databaseID uuid.UUID) error { + return s.initializeDefaultConfig(databaseID) +} + func (s *BackupConfigService) initializeDefaultConfig( databaseID uuid.UUID, ) error { diff --git a/backend/internal/features/databases/controller.go b/backend/internal/features/databases/controller.go index 3dcf3ce..8a6ce43 100644 --- a/backend/internal/features/databases/controller.go +++ b/backend/internal/features/databases/controller.go @@ -2,15 +2,18 @@ package databases import ( "net/http" - "postgresus-backend/internal/features/users" + users_middleware "postgresus-backend/internal/features/users/middleware" + users_services "postgresus-backend/internal/features/users/services" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "github.com/gin-gonic/gin" "github.com/google/uuid" ) type DatabaseController struct { - databaseService *DatabaseService - userService *users.UserService + databaseService *DatabaseService + userService *users_services.UserService + workspaceService *workspaces_services.WorkspaceService } func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) { @@ -28,36 +31,35 @@ func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) { // CreateDatabase // @Summary Create a new database -// @Description Create a new database configuration +// @Description Create a new database configuration in a workspace // @Tags databases // @Accept json // @Produce json -// @Param request body Database true "Database creation data" +// @Param request body Database true "Database creation data with workspaceId" // @Success 201 {object} Database // @Failure 400 // @Failure 401 // @Failure 500 // @Router /databases/create [post] func (c *DatabaseController) CreateDatabase(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + var request Database if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + if request.WorkspaceID == nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceId is required"}) return } - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - - database, err := c.databaseService.CreateDatabase(user, &request) + database, err := c.databaseService.CreateDatabase(user, *request.WorkspaceID, &request) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -79,24 +81,18 @@ func (c *DatabaseController) CreateDatabase(ctx *gin.Context) { // @Failure 500 // @Router /databases/update [post] func (c *DatabaseController) UpdateDatabase(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + var request Database if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - if err := c.databaseService.UpdateDatabase(user, &request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -116,24 +112,18 @@ func (c *DatabaseController) UpdateDatabase(ctx *gin.Context) { // @Failure 500 // @Router /databases/{id} [delete] func (c *DatabaseController) DeleteDatabase(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 } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - if err := c.databaseService.DeleteDatabase(user, id); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -153,24 +143,18 @@ func (c *DatabaseController) DeleteDatabase(ctx *gin.Context) { // @Failure 401 // @Router /databases/{id} [get] func (c *DatabaseController) GetDatabase(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 } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - database, err := c.databaseService.GetDatabase(user, id) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -181,30 +165,38 @@ func (c *DatabaseController) GetDatabase(ctx *gin.Context) { } // GetDatabases -// @Summary Get databases -// @Description Get all databases for the authenticated user +// @Summary Get databases by workspace +// @Description Get all databases for a specific workspace // @Tags databases // @Produce json +// @Param workspace_id query string true "Workspace ID" // @Success 200 {array} Database +// @Failure 400 // @Failure 401 // @Failure 500 // @Router /databases [get] func (c *DatabaseController) GetDatabases(ctx *gin.Context) { - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + workspaceIDStr := ctx.Query("workspace_id") + if workspaceIDStr == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter is required"}) return } - databases, err := c.databaseService.GetDatabasesByUser(user) + workspaceID, err := uuid.Parse(workspaceIDStr) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace_id"}) + return + } + + databases, err := c.databaseService.GetDatabasesByWorkspace(user, workspaceID) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -222,24 +214,18 @@ func (c *DatabaseController) GetDatabases(ctx *gin.Context) { // @Failure 500 // @Router /databases/{id}/test-connection [post] func (c *DatabaseController) TestDatabaseConnection(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 } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - if err := c.databaseService.TestDatabaseConnection(user, id); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -259,27 +245,18 @@ func (c *DatabaseController) TestDatabaseConnection(ctx *gin.Context) { // @Failure 401 // @Router /databases/test-connection-direct [post] func (c *DatabaseController) TestDatabaseConnectionDirect(ctx *gin.Context) { + _, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + var request Database if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - - // Set user ID for validation purposes - request.UserID = user.ID - if err := c.databaseService.TestDatabaseConnectionDirect(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -294,31 +271,38 @@ func (c *DatabaseController) TestDatabaseConnectionDirect(ctx *gin.Context) { // @Tags databases // @Produce json // @Param id path string true "Notifier ID" +// @Param workspace_id query string true "Workspace ID" // @Success 200 {object} map[string]bool // @Failure 400 // @Failure 401 // @Failure 500 // @Router /databases/notifier/{id}/is-using [get] func (c *DatabaseController) IsNotifierUsing(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 } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + workspaceIDStr := ctx.Query("workspace_id") + if workspaceIDStr == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter is required"}) return } - user, err := c.userService.GetUserFromToken(authorizationHeader) + workspaceID, err := uuid.Parse(workspaceIDStr) if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace_id"}) return } - isUsing, err := c.databaseService.IsNotifierUsing(user, id) + isUsing, err := c.databaseService.IsNotifierUsing(user, workspaceID, id) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -339,24 +323,18 @@ func (c *DatabaseController) IsNotifierUsing(ctx *gin.Context) { // @Failure 500 // @Router /databases/{id}/copy [post] func (c *DatabaseController) CopyDatabase(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 } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - copiedDatabase, err := c.databaseService.CopyDatabase(user, id) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/backend/internal/features/databases/controller_test.go b/backend/internal/features/databases/controller_test.go new file mode 100644 index 0000000..8457eda --- /dev/null +++ b/backend/internal/features/databases/controller_test.go @@ -0,0 +1,770 @@ +package databases + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "postgresus-backend/internal/features/databases/databases/postgresql" + users_enums "postgresus-backend/internal/features/users/enums" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + "postgresus-backend/internal/util/tools" +) + +func createTestRouter() *gin.Engine { + router := workspaces_testing.CreateTestRouter( + workspaces_controllers.GetWorkspaceController(), + workspaces_controllers.GetMembershipController(), + GetDatabaseController(), + ) + return router +} + +func Test_CreateDatabase_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can create database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusCreated, + }, + { + name: "workspace member can create database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusCreated, + }, + { + name: "workspace viewer cannot create database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can create database", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusCreated, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + + testDbName := "test_db" + request := Database{ + Name: "Test Database", + WorkspaceID: &workspace.ID, + Type: DatabaseTypePostgres, + Postgresql: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + Database: &testDbName, + }, + } + + var response Database + testResp := test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/databases/create", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + &response, + ) + + if tt.expectSuccess { + assert.Equal(t, "Test Database", response.Name) + assert.NotEqual(t, uuid.Nil, response.ID) + } else { + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_CreateDatabase_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + + testDbName := "test_db" + request := Database{ + Name: "Test Database", + WorkspaceID: &workspace.ID, + Type: DatabaseTypePostgres, + Postgresql: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + Database: &testDbName, + }, + } + + testResp := test_utils.MakePostRequest( + t, + router, + "/api/v1/databases/create", + "Bearer "+nonMember.Token, + request, + http.StatusBadRequest, + ) + + assert.Contains(t, string(testResp.Body), "insufficient permissions") +} + +func Test_UpdateDatabase_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can update database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can update database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace viewer cannot update database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can update database", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + + database.Name = "Updated Database" + + var response Database + testResp := test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/databases/update", + "Bearer "+testUserToken, + database, + tt.expectedStatusCode, + &response, + ) + + if tt.expectSuccess { + assert.Equal(t, "Updated Database", response.Name) + } else { + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_UpdateDatabase_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + database.Name = "Hacked Name" + + testResp := test_utils.MakePostRequest( + t, + router, + "/api/v1/databases/update", + "Bearer "+nonMember.Token, + database, + http.StatusBadRequest, + ) + + assert.Contains(t, string(testResp.Body), "insufficient permissions") +} + +func Test_DeleteDatabase_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can delete database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "workspace member can delete database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "workspace viewer cannot delete database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusInternalServerError, + }, + { + name: "global admin can delete database", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusNoContent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + + testResp := test_utils.MakeDeleteRequest( + t, + router, + "/api/v1/databases/"+database.ID.String(), + "Bearer "+testUserToken, + tt.expectedStatusCode, + ) + + if !tt.expectSuccess { + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_GetDatabase_PermissionsEnforced(t *testing.T) { + memberRole := users_enums.WorkspaceRoleViewer + tests := []struct { + name string + userRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace member can get database", + userRole: &memberRole, + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-member cannot get database", + userRole: nil, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can get database", + userRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var testUser string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUser = admin.Token + } else if tt.userRole != nil { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.userRole, owner.Token, router) + testUser = member.Token + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUser = nonMember.Token + } + + var response Database + testResp := test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/databases/"+database.ID.String(), + "Bearer "+testUser, + tt.expectedStatusCode, + &response, + ) + + if tt.expectSuccess { + assert.Equal(t, database.ID, response.ID) + assert.Equal(t, "Test Database", response.Name) + } else { + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_GetDatabasesByWorkspace_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + isMember bool + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace member can list databases", + isMember: true, + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-member cannot list databases", + isMember: false, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can list databases", + isMember: false, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + createTestDatabaseViaAPI("Database 1", workspace.ID, owner.Token, router) + createTestDatabaseViaAPI("Database 2", workspace.ID, owner.Token, router) + + var testUser string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUser = admin.Token + } else if tt.isMember { + testUser = owner.Token + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUser = nonMember.Token + } + + if tt.expectSuccess { + var response []Database + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/databases?workspace_id="+workspace.ID.String(), + "Bearer "+testUser, + tt.expectedStatusCode, + &response, + ) + assert.GreaterOrEqual(t, len(response), 2) + } else { + testResp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/databases?workspace_id="+workspace.ID.String(), + "Bearer "+testUser, + tt.expectedStatusCode, + ) + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_GetDatabasesByWorkspace_WhenMultipleDatabasesExist_ReturnsCorrectCount(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + createTestDatabaseViaAPI("Database 1", workspace.ID, owner.Token, router) + createTestDatabaseViaAPI("Database 2", workspace.ID, owner.Token, router) + createTestDatabaseViaAPI("Database 3", workspace.ID, owner.Token, router) + + var response []Database + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/databases?workspace_id="+workspace.ID.String(), + "Bearer "+owner.Token, + http.StatusOK, + &response, + ) + + assert.Equal(t, 3, len(response)) +} + +func Test_GetDatabasesByWorkspace_EnsuresCrossWorkspaceIsolation(t *testing.T) { + router := createTestRouter() + owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace1 := workspaces_testing.CreateTestWorkspace("Workspace 1", owner1, router) + + owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router) + + createTestDatabaseViaAPI("Workspace1 DB1", workspace1.ID, owner1.Token, router) + createTestDatabaseViaAPI("Workspace1 DB2", workspace1.ID, owner1.Token, router) + + createTestDatabaseViaAPI("Workspace2 DB1", workspace2.ID, owner2.Token, router) + + var workspace1Dbs []Database + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/databases?workspace_id="+workspace1.ID.String(), + "Bearer "+owner1.Token, + http.StatusOK, + &workspace1Dbs, + ) + + var workspace2Dbs []Database + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/databases?workspace_id="+workspace2.ID.String(), + "Bearer "+owner2.Token, + http.StatusOK, + &workspace2Dbs, + ) + + assert.Equal(t, 2, len(workspace1Dbs)) + assert.Equal(t, 1, len(workspace2Dbs)) + + for _, db := range workspace1Dbs { + assert.Equal(t, workspace1.ID, *db.WorkspaceID) + } + + for _, db := range workspace2Dbs { + assert.Equal(t, workspace2.ID, *db.WorkspaceID) + } +} + +func Test_CopyDatabase_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can copy database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusCreated, + }, + { + name: "workspace member can copy database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusCreated, + }, + { + name: "workspace viewer cannot copy database", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can copy database", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusCreated, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + + var response Database + testResp := test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/databases/"+database.ID.String()+"/copy", + "Bearer "+testUserToken, + nil, + tt.expectedStatusCode, + &response, + ) + + if tt.expectSuccess { + assert.NotEqual(t, database.ID, response.ID) + assert.Contains(t, response.Name, "(Copy)") + } else { + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_CopyDatabase_CopyStaysInSameWorkspace(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var response Database + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/databases/"+database.ID.String()+"/copy", + "Bearer "+owner.Token, + nil, + http.StatusCreated, + &response, + ) + + assert.NotEqual(t, database.ID, response.ID) + assert.Equal(t, "Test Database (Copy)", response.Name) + assert.Equal(t, workspace.ID, *response.WorkspaceID) + assert.Equal(t, database.Type, response.Type) +} + +func Test_TestConnection_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + isMember bool + isGlobalAdmin bool + expectAccessGranted bool + expectedStatusCodeOnErr int + }{ + { + name: "workspace member can test connection", + isMember: true, + isGlobalAdmin: false, + expectAccessGranted: true, + expectedStatusCodeOnErr: http.StatusBadRequest, + }, + { + name: "non-member cannot test connection", + isMember: false, + isGlobalAdmin: false, + expectAccessGranted: false, + expectedStatusCodeOnErr: http.StatusBadRequest, + }, + { + name: "global admin can test connection", + isMember: false, + isGlobalAdmin: true, + expectAccessGranted: true, + expectedStatusCodeOnErr: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var testUser string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUser = admin.Token + } else if tt.isMember { + testUser = owner.Token + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUser = nonMember.Token + } + + w := workspaces_testing.MakeAPIRequest( + router, + "POST", + "/api/v1/databases/"+database.ID.String()+"/test-connection", + "Bearer "+testUser, + nil, + ) + + body := w.Body.String() + + if tt.expectAccessGranted { + assert.True( + t, + w.Code == http.StatusOK || + (w.Code == http.StatusBadRequest && strings.Contains(body, "connect")), + "Expected 200 OK or 400 with connection error, got %d: %s", + w.Code, + body, + ) + } else { + assert.Equal(t, tt.expectedStatusCodeOnErr, w.Code) + assert.Contains(t, body, "insufficient permissions") + } + }) + } +} + +func createTestDatabaseViaAPI( + name string, + workspaceID uuid.UUID, + token string, + router *gin.Engine, +) *Database { + testDbName := "test_db" + request := Database{ + Name: name, + WorkspaceID: &workspaceID, + Type: DatabaseTypePostgres, + Postgresql: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + Database: &testDbName, + }, + } + + w := workspaces_testing.MakeAPIRequest( + router, + "POST", + "/api/v1/databases/create", + "Bearer "+token, + request, + ) + + if w.Code != http.StatusCreated { + panic( + fmt.Sprintf("Failed to create database. Status: %d, Body: %s", w.Code, w.Body.String()), + ) + } + + var database Database + if err := json.Unmarshal(w.Body.Bytes(), &database); err != nil { + panic(err) + } + + return &database +} diff --git a/backend/internal/features/databases/di.go b/backend/internal/features/databases/di.go index 9a8a733..1a66995 100644 --- a/backend/internal/features/databases/di.go +++ b/backend/internal/features/databases/di.go @@ -1,8 +1,10 @@ package databases import ( + audit_logs "postgresus-backend/internal/features/audit_logs" "postgresus-backend/internal/features/notifiers" - "postgresus-backend/internal/features/users" + users_services "postgresus-backend/internal/features/users/services" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "postgresus-backend/internal/util/logger" ) @@ -15,11 +17,14 @@ var databaseService = &DatabaseService{ []DatabaseCreationListener{}, []DatabaseRemoveListener{}, []DatabaseCopyListener{}, + workspaces_services.GetWorkspaceService(), + audit_logs.GetAuditLogService(), } var databaseController = &DatabaseController{ databaseService, - users.GetUserService(), + users_services.GetUserService(), + workspaces_services.GetWorkspaceService(), } func GetDatabaseService() *DatabaseService { @@ -29,3 +34,7 @@ func GetDatabaseService() *DatabaseService { func GetDatabaseController() *DatabaseController { return databaseController } + +func SetupDependencies() { + workspaces_services.GetWorkspaceService().AddWorkspaceDeletionListener(databaseService) +} diff --git a/backend/internal/features/databases/model.go b/backend/internal/features/databases/model.go index 9f9acda..f21a12f 100644 --- a/backend/internal/features/databases/model.go +++ b/backend/internal/features/databases/model.go @@ -11,10 +11,13 @@ import ( ) type Database struct { - ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"` - UserID uuid.UUID `json:"userId" gorm:"column:user_id;type:uuid;not null"` - Name string `json:"name" gorm:"column:name;type:text;not null"` - Type DatabaseType `json:"type" gorm:"column:type;type:text;not null"` + ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"` + + // WorkspaceID can be null when a database is created via restore operation + // outside the context of any workspace + WorkspaceID *uuid.UUID `json:"workspaceId" gorm:"column:workspace_id;type:uuid"` + Name string `json:"name" gorm:"column:name;type:text;not null"` + Type DatabaseType `json:"type" gorm:"column:type;type:text;not null"` Postgresql *postgresql.PostgresqlDatabase `json:"postgresql,omitempty" gorm:"foreignKey:DatabaseID"` diff --git a/backend/internal/features/databases/repository.go b/backend/internal/features/databases/repository.go index d53f7e1..020902f 100644 --- a/backend/internal/features/databases/repository.go +++ b/backend/internal/features/databases/repository.go @@ -92,14 +92,14 @@ func (r *DatabaseRepository) FindByID(id uuid.UUID) (*Database, error) { return &database, nil } -func (r *DatabaseRepository) FindByUserID(userID uuid.UUID) ([]*Database, error) { +func (r *DatabaseRepository) FindByWorkspaceID(workspaceID uuid.UUID) ([]*Database, error) { var databases []*Database if err := storage. GetDb(). Preload("Postgresql"). Preload("Notifiers"). - Where("user_id = ?", userID). + Where("workspace_id = ?", workspaceID). Order("CASE WHEN health_status = 'UNAVAILABLE' THEN 1 WHEN health_status = 'AVAILABLE' THEN 2 WHEN health_status IS NULL THEN 3 ELSE 4 END, name ASC"). Find(&databases).Error; err != nil { return nil, err diff --git a/backend/internal/features/databases/service.go b/backend/internal/features/databases/service.go index 52090e7..37f8ef9 100644 --- a/backend/internal/features/databases/service.go +++ b/backend/internal/features/databases/service.go @@ -2,11 +2,15 @@ package databases import ( "errors" + "fmt" "log/slog" + "time" + + audit_logs "postgresus-backend/internal/features/audit_logs" "postgresus-backend/internal/features/databases/databases/postgresql" "postgresus-backend/internal/features/notifiers" users_models "postgresus-backend/internal/features/users/models" - "time" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "github.com/google/uuid" ) @@ -19,6 +23,9 @@ type DatabaseService struct { dbCreationListener []DatabaseCreationListener dbRemoveListener []DatabaseRemoveListener dbCopyListener []DatabaseCopyListener + + workspaceService *workspaces_services.WorkspaceService + auditLogService *audit_logs.AuditLogService } func (s *DatabaseService) AddDbCreationListener( @@ -41,15 +48,24 @@ func (s *DatabaseService) AddDbCopyListener( func (s *DatabaseService) CreateDatabase( user *users_models.User, + workspaceID uuid.UUID, database *Database, ) (*Database, error) { - database.UserID = user.ID + canManage, err := s.workspaceService.CanUserManageDBs(workspaceID, user) + if err != nil { + return nil, err + } + if !canManage { + return nil, errors.New("insufficient permissions to create database in this workspace") + } + + database.WorkspaceID = &workspaceID if err := database.Validate(); err != nil { return nil, err } - database, err := s.dbRepository.Save(database) + database, err = s.dbRepository.Save(database) if err != nil { return nil, err } @@ -58,6 +74,12 @@ func (s *DatabaseService) CreateDatabase( listener.OnDatabaseCreated(database.ID) } + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Database created: %s", database.Name), + &user.ID, + &workspaceID, + ) + return database, nil } @@ -74,11 +96,18 @@ func (s *DatabaseService) UpdateDatabase( return err } - if existingDatabase.UserID != user.ID { - return errors.New("you have not access to this database") + if existingDatabase.WorkspaceID == nil { + return errors.New("cannot update database without workspace") + } + + canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user) + if err != nil { + return err + } + if !canManage { + return errors.New("insufficient permissions to update this database") } - // Validate the update if err := database.ValidateUpdate(*existingDatabase, *database); err != nil { return err } @@ -92,6 +121,12 @@ func (s *DatabaseService) UpdateDatabase( return err } + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Database updated: %s", database.Name), + &user.ID, + existingDatabase.WorkspaceID, + ) + return nil } @@ -104,8 +139,16 @@ func (s *DatabaseService) DeleteDatabase( return err } - if existingDatabase.UserID != user.ID { - return errors.New("you have not access to this database") + if existingDatabase.WorkspaceID == nil { + return errors.New("cannot delete database without workspace") + } + + canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user) + if err != nil { + return err + } + if !canManage { + return errors.New("insufficient permissions to delete this database") } for _, listener := range s.dbRemoveListener { @@ -114,6 +157,12 @@ func (s *DatabaseService) DeleteDatabase( } } + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Database deleted: %s", existingDatabase.Name), + &user.ID, + existingDatabase.WorkspaceID, + ) + return s.dbRepository.Delete(id) } @@ -126,21 +175,39 @@ func (s *DatabaseService) GetDatabase( return nil, err } - if database.UserID != user.ID { - return nil, errors.New("you have not access to this database") + if database.WorkspaceID == nil { + return nil, errors.New("cannot access database without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user) + if err != nil { + return nil, err + } + if !canAccess { + return nil, errors.New("insufficient permissions to access this database") } return database, nil } -func (s *DatabaseService) GetDatabasesByUser( +func (s *DatabaseService) GetDatabasesByWorkspace( user *users_models.User, + workspaceID uuid.UUID, ) ([]*Database, error) { - return s.dbRepository.FindByUserID(user.ID) + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(workspaceID, user) + if err != nil { + return nil, err + } + if !canAccess { + return nil, errors.New("insufficient permissions to access this workspace") + } + + return s.dbRepository.FindByWorkspaceID(workspaceID) } func (s *DatabaseService) IsNotifierUsing( user *users_models.User, + workspaceID uuid.UUID, notifierID uuid.UUID, ) (bool, error) { _, err := s.notifierService.GetNotifier(user, notifierID) @@ -160,8 +227,16 @@ func (s *DatabaseService) TestDatabaseConnection( return err } - if database.UserID != user.ID { - return errors.New("you have not access to this database") + if database.WorkspaceID == nil { + return errors.New("cannot test connection for database without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user) + if err != nil { + return err + } + if !canAccess { + return errors.New("insufficient permissions to test connection for this database") } err = database.TestConnection(s.logger) @@ -237,13 +312,21 @@ func (s *DatabaseService) CopyDatabase( return nil, err } - if existingDatabase.UserID != user.ID { - return nil, errors.New("you have not access to this database") + if existingDatabase.WorkspaceID == nil { + return nil, errors.New("cannot copy database without workspace") + } + + canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user) + if err != nil { + return nil, err + } + if !canManage { + return nil, errors.New("insufficient permissions to copy this database") } newDatabase := &Database{ ID: uuid.Nil, - UserID: user.ID, + WorkspaceID: existingDatabase.WorkspaceID, Name: existingDatabase.Name + " (Copy)", Type: existingDatabase.Type, Notifiers: existingDatabase.Notifiers, @@ -286,6 +369,12 @@ func (s *DatabaseService) CopyDatabase( listener.OnDatabaseCopied(databaseID, copiedDatabase.ID) } + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Database copied: %s to %s", existingDatabase.Name, copiedDatabase.Name), + &user.ID, + existingDatabase.WorkspaceID, + ) + return copiedDatabase, nil } @@ -306,3 +395,19 @@ func (s *DatabaseService) SetHealthStatus( return nil } + +func (s *DatabaseService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error { + databases, err := s.dbRepository.FindByWorkspaceID(workspaceID) + if err != nil { + return err + } + + if len(databases) > 0 { + return fmt.Errorf( + "workspace contains %d databases that must be deleted", + len(databases), + ) + } + + return nil +} diff --git a/backend/internal/features/databases/testing.go b/backend/internal/features/databases/testing.go index 5c767e4..eff1764 100644 --- a/backend/internal/features/databases/testing.go +++ b/backend/internal/features/databases/testing.go @@ -10,14 +10,14 @@ import ( ) func CreateTestDatabase( - userID uuid.UUID, + workspaceID uuid.UUID, storage *storages.Storage, notifier *notifiers.Notifier, ) *Database { database := &Database{ - UserID: userID, - Name: "test " + uuid.New().String(), - Type: DatabaseTypePostgres, + WorkspaceID: &workspaceID, + Name: "test " + uuid.New().String(), + Type: DatabaseTypePostgres, Postgresql: &postgresql.PostgresqlDatabase{ Version: tools.PostgresqlVersion16, diff --git a/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go b/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go index cd4b991..09cb7fd 100644 --- a/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go +++ b/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go @@ -10,23 +10,34 @@ import ( healthcheck_config "postgresus-backend/internal/features/healthcheck/config" "postgresus-backend/internal/features/notifiers" "postgresus-backend/internal/features/storages" - "postgresus-backend/internal/features/users" + users_enums "postgresus-backend/internal/features/users/enums" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func Test_CheckPgHealthUseCase(t *testing.T) { - user := users.GetTestUser() + user := users_testing.CreateTestUser(users_enums.UserRoleAdmin) - storage := storages.CreateTestStorage(user.UserID) - notifier := notifiers.CreateTestNotifier(user.UserID) + // Create workspace directly via service + workspace, err := workspaces_testing.CreateTestWorkspaceDirect("Test Workspace", user.UserID) + if err != nil { + t.Fatalf("Failed to create workspace: %v", err) + } - defer storages.RemoveTestStorage(storage.ID) - defer notifiers.RemoveTestNotifier(notifier) + storage := storages.CreateTestStorage(workspace.ID) + notifier := notifiers.CreateTestNotifier(workspace.ID) + + defer func() { + storages.RemoveTestStorage(storage.ID) + notifiers.RemoveTestNotifier(notifier) + workspaces_testing.RemoveTestWorkspaceDirect(workspace.ID) + }() t.Run("Test_DbAttemptFailed_DbMarkedAsUnavailable", func(t *testing.T) { - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + database := databases.CreateTestDatabase(workspace.ID, storage, notifier) defer databases.RemoveTestDatabase(database) // Setup mock notifier sender @@ -94,7 +105,7 @@ func Test_CheckPgHealthUseCase(t *testing.T) { t.Run( "Test_DbShouldBeConsideredAsDownOnThirdFailedAttempt_DbNotMarkerdAsDownAfterFirstAttempt", func(t *testing.T) { - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + database := databases.CreateTestDatabase(workspace.ID, storage, notifier) defer databases.RemoveTestDatabase(database) // Setup mock notifier sender @@ -160,7 +171,7 @@ func Test_CheckPgHealthUseCase(t *testing.T) { t.Run( "Test_DbShouldBeConsideredAsDownOnThirdFailedAttempt_DbMarkerdAsDownAfterThirdFailedAttempt", func(t *testing.T) { - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + database := databases.CreateTestDatabase(workspace.ID, storage, notifier) defer databases.RemoveTestDatabase(database) // Make sure DB is available @@ -237,7 +248,7 @@ func Test_CheckPgHealthUseCase(t *testing.T) { ) t.Run("Test_UnavailableDbAttemptSucceed_DbMarkedAsAvailable", func(t *testing.T) { - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + database := databases.CreateTestDatabase(workspace.ID, storage, notifier) defer databases.RemoveTestDatabase(database) // Make sure DB is unavailable @@ -311,7 +322,7 @@ func Test_CheckPgHealthUseCase(t *testing.T) { t.Run( "Test_DbHealthcheckExecutedFast_HealthcheckNotExecutedFasterThanInterval", func(t *testing.T) { - database := databases.CreateTestDatabase(user.UserID, storage, notifier) + database := databases.CreateTestDatabase(workspace.ID, storage, notifier) defer databases.RemoveTestDatabase(database) // Setup mock notifier sender diff --git a/backend/internal/features/healthcheck/attempt/controller.go b/backend/internal/features/healthcheck/attempt/controller.go index 3037881..60a2801 100644 --- a/backend/internal/features/healthcheck/attempt/controller.go +++ b/backend/internal/features/healthcheck/attempt/controller.go @@ -2,7 +2,7 @@ package healthcheck_attempt import ( "net/http" - "postgresus-backend/internal/features/users" + users_middleware "postgresus-backend/internal/features/users/middleware" "time" "github.com/gin-gonic/gin" @@ -11,7 +11,6 @@ import ( type HealthcheckAttemptController struct { healthcheckAttemptService *HealthcheckAttemptService - userService *users.UserService } func (c *HealthcheckAttemptController) RegisterRoutes(router *gin.RouterGroup) { @@ -31,9 +30,9 @@ func (c *HealthcheckAttemptController) RegisterRoutes(router *gin.RouterGroup) { // @Failure 401 // @Router /healthcheck-attempts/{databaseId} [get] func (c *HealthcheckAttemptController) GetAttemptsByDatabase(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } @@ -43,7 +42,7 @@ func (c *HealthcheckAttemptController) GetAttemptsByDatabase(ctx *gin.Context) { return } - afterDate := time.Now().UTC() + afterDate := time.Now().UTC().Add(-7 * 24 * time.Hour) if afterDateStr := ctx.Query("afterDate"); afterDateStr != "" { parsedDate, err := time.Parse(time.RFC3339, afterDateStr) if err != nil { diff --git a/backend/internal/features/healthcheck/attempt/controller_test.go b/backend/internal/features/healthcheck/attempt/controller_test.go new file mode 100644 index 0000000..f2b5800 --- /dev/null +++ b/backend/internal/features/healthcheck/attempt/controller_test.go @@ -0,0 +1,261 @@ +package healthcheck_attempt + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/databases/databases/postgresql" + users_enums "postgresus-backend/internal/features/users/enums" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + "postgresus-backend/internal/util/tools" +) + +func createTestRouter() *gin.Engine { + router := workspaces_testing.CreateTestRouter( + workspaces_controllers.GetWorkspaceController(), + workspaces_controllers.GetMembershipController(), + databases.GetDatabaseController(), + GetHealthcheckAttemptController(), + ) + return router +} + +func Test_GetAttemptsByDatabase_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can get healthcheck attempts", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can get healthcheck attempts", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can get healthcheck attempts", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace viewer can get healthcheck attempts", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can get healthcheck attempts", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-member cannot get healthcheck attempts", + workspaceRole: nil, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + pastTime := time.Now().UTC().Add(-1 * time.Hour) + createTestHealthcheckAttemptWithTime( + database.ID, + databases.HealthStatusAvailable, + pastTime, + ) + createTestHealthcheckAttemptWithTime( + database.ID, + databases.HealthStatusUnavailable, + pastTime.Add(-30*time.Minute), + ) + + 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUserToken = nonMember.Token + } + + if tt.expectSuccess { + var response []*HealthcheckAttempt + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/healthcheck-attempts/"+database.ID.String(), + "Bearer "+testUserToken, + tt.expectedStatusCode, + &response, + ) + + assert.GreaterOrEqual(t, len(response), 2) + } else { + testResp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/healthcheck-attempts/"+database.ID.String(), + "Bearer "+testUserToken, + tt.expectedStatusCode, + ) + assert.Contains(t, string(testResp.Body), "forbidden") + } + }) + } +} + +func Test_GetAttemptsByDatabase_FiltersByAfterDate(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + oldTime := time.Now().UTC().Add(-2 * time.Hour) + recentTime := time.Now().UTC().Add(-30 * time.Minute) + + createTestHealthcheckAttemptWithTime(database.ID, databases.HealthStatusAvailable, oldTime) + createTestHealthcheckAttemptWithTime(database.ID, databases.HealthStatusUnavailable, recentTime) + createTestHealthcheckAttempt(database.ID, databases.HealthStatusAvailable) + + afterDate := time.Now().UTC().Add(-1 * time.Hour) + var response []*HealthcheckAttempt + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf( + "/api/v1/healthcheck-attempts/%s?afterDate=%s", + database.ID.String(), + afterDate.Format(time.RFC3339), + ), + "Bearer "+owner.Token, + http.StatusOK, + &response, + ) + + assert.Equal(t, 2, len(response)) + for _, attempt := range response { + assert.True(t, attempt.CreatedAt.After(afterDate) || attempt.CreatedAt.Equal(afterDate)) + } +} + +func Test_GetAttemptsByDatabase_ReturnsEmptyListForNewDatabase(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var response []*HealthcheckAttempt + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/healthcheck-attempts/"+database.ID.String(), + "Bearer "+owner.Token, + http.StatusOK, + &response, + ) + + assert.Equal(t, 0, len(response)) +} + +func createTestDatabaseViaAPI( + name string, + workspaceID uuid.UUID, + token string, + router *gin.Engine, +) *databases.Database { + testDbName := "test_db" + request := databases.Database{ + WorkspaceID: &workspaceID, + Name: name, + Type: databases.DatabaseTypePostgres, + Postgresql: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + Database: &testDbName, + }, + } + + w := workspaces_testing.MakeAPIRequest( + router, + "POST", + "/api/v1/databases/create", + "Bearer "+token, + request, + ) + + if w.Code != http.StatusCreated { + panic("Failed to create database") + } + + var database databases.Database + if err := json.Unmarshal(w.Body.Bytes(), &database); err != nil { + panic(err) + } + return &database +} + +func createTestHealthcheckAttempt(databaseID uuid.UUID, status databases.HealthStatus) { + createTestHealthcheckAttemptWithTime(databaseID, status, time.Now().UTC()) +} + +func createTestHealthcheckAttemptWithTime( + databaseID uuid.UUID, + status databases.HealthStatus, + createdAt time.Time, +) { + repo := GetHealthcheckAttemptRepository() + attempt := &HealthcheckAttempt{ + ID: uuid.New(), + DatabaseID: databaseID, + Status: status, + CreatedAt: createdAt, + } + if err := repo.Create(attempt); err != nil { + panic("Failed to create test healthcheck attempt: " + err.Error()) + } +} diff --git a/backend/internal/features/healthcheck/attempt/di.go b/backend/internal/features/healthcheck/attempt/di.go index e434d7c..43aaf24 100644 --- a/backend/internal/features/healthcheck/attempt/di.go +++ b/backend/internal/features/healthcheck/attempt/di.go @@ -4,7 +4,7 @@ import ( "postgresus-backend/internal/features/databases" healthcheck_config "postgresus-backend/internal/features/healthcheck/config" "postgresus-backend/internal/features/notifiers" - "postgresus-backend/internal/features/users" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "postgresus-backend/internal/util/logger" ) @@ -12,6 +12,7 @@ var healthcheckAttemptRepository = &HealthcheckAttemptRepository{} var healthcheckAttemptService = &HealthcheckAttemptService{ healthcheckAttemptRepository, databases.GetDatabaseService(), + workspaces_services.GetWorkspaceService(), } var checkPgHealthUseCase = &CheckPgHealthUseCase{ @@ -27,7 +28,10 @@ var healthcheckAttemptBackgroundService = &HealthcheckAttemptBackgroundService{ } var healthcheckAttemptController = &HealthcheckAttemptController{ healthcheckAttemptService, - users.GetUserService(), +} + +func GetHealthcheckAttemptRepository() *HealthcheckAttemptRepository { + return healthcheckAttemptRepository } func GetHealthcheckAttemptService() *HealthcheckAttemptService { diff --git a/backend/internal/features/healthcheck/attempt/repository.go b/backend/internal/features/healthcheck/attempt/repository.go index 105c0fe..7f9a855 100644 --- a/backend/internal/features/healthcheck/attempt/repository.go +++ b/backend/internal/features/healthcheck/attempt/repository.go @@ -53,7 +53,7 @@ func (r *HealthcheckAttemptRepository) DeleteOlderThan( Delete(&HealthcheckAttempt{}).Error } -func (r *HealthcheckAttemptRepository) Insert( +func (r *HealthcheckAttemptRepository) Create( attempt *HealthcheckAttempt, ) error { if attempt.ID == uuid.Nil { @@ -67,6 +67,12 @@ func (r *HealthcheckAttemptRepository) Insert( return storage.GetDb().Create(attempt).Error } +func (r *HealthcheckAttemptRepository) Insert( + attempt *HealthcheckAttempt, +) error { + return r.Create(attempt) +} + func (r *HealthcheckAttemptRepository) FindByDatabaseIDWithLimit( databaseID uuid.UUID, limit int, diff --git a/backend/internal/features/healthcheck/attempt/service.go b/backend/internal/features/healthcheck/attempt/service.go index 6dde033..a660686 100644 --- a/backend/internal/features/healthcheck/attempt/service.go +++ b/backend/internal/features/healthcheck/attempt/service.go @@ -4,6 +4,7 @@ import ( "errors" "postgresus-backend/internal/features/databases" users_models "postgresus-backend/internal/features/users/models" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "time" "github.com/google/uuid" @@ -12,6 +13,7 @@ import ( type HealthcheckAttemptService struct { healthcheckAttemptRepository *HealthcheckAttemptRepository databaseService *databases.DatabaseService + workspaceService *workspaces_services.WorkspaceService } func (s *HealthcheckAttemptService) GetAttemptsByDatabase( @@ -24,7 +26,15 @@ func (s *HealthcheckAttemptService) GetAttemptsByDatabase( return nil, err } - if database.UserID != user.ID { + if database.WorkspaceID == nil { + return nil, errors.New("cannot access healthcheck attempts for databases without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, &user) + if err != nil { + return nil, err + } + if !canAccess { return nil, errors.New("forbidden") } diff --git a/backend/internal/features/healthcheck/config/controller.go b/backend/internal/features/healthcheck/config/controller.go index 3caa571..5e00311 100644 --- a/backend/internal/features/healthcheck/config/controller.go +++ b/backend/internal/features/healthcheck/config/controller.go @@ -2,7 +2,7 @@ package healthcheck_config import ( "net/http" - "postgresus-backend/internal/features/users" + users_middleware "postgresus-backend/internal/features/users/middleware" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -10,7 +10,6 @@ import ( type HealthcheckConfigController struct { healthcheckConfigService *HealthcheckConfigService - userService *users.UserService } func (c *HealthcheckConfigController) RegisterRoutes(router *gin.RouterGroup) { @@ -31,9 +30,9 @@ func (c *HealthcheckConfigController) RegisterRoutes(router *gin.RouterGroup) { // @Failure 401 // @Router /healthcheck-config [post] func (c *HealthcheckConfigController) SaveHealthcheckConfig(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } @@ -65,9 +64,9 @@ func (c *HealthcheckConfigController) SaveHealthcheckConfig(ctx *gin.Context) { // @Failure 401 // @Router /healthcheck-config/{databaseId} [get] func (c *HealthcheckConfigController) GetHealthcheckConfig(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } diff --git a/backend/internal/features/healthcheck/config/controller_test.go b/backend/internal/features/healthcheck/config/controller_test.go new file mode 100644 index 0000000..a1b8cce --- /dev/null +++ b/backend/internal/features/healthcheck/config/controller_test.go @@ -0,0 +1,328 @@ +package healthcheck_config + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/databases/databases/postgresql" + users_enums "postgresus-backend/internal/features/users/enums" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + "postgresus-backend/internal/util/tools" +) + +func createTestRouter() *gin.Engine { + router := workspaces_testing.CreateTestRouter( + workspaces_controllers.GetWorkspaceController(), + workspaces_controllers.GetMembershipController(), + databases.GetDatabaseController(), + GetHealthcheckConfigController(), + ) + return router +} + +func Test_SaveHealthcheckConfig_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can save healthcheck config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can save healthcheck config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can save healthcheck config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace viewer cannot save healthcheck config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "global admin can save healthcheck config", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + + request := HealthcheckConfigDTO{ + DatabaseID: database.ID, + IsHealthcheckEnabled: true, + IsSentNotificationWhenUnavailable: true, + IntervalMinutes: 5, + AttemptsBeforeConcideredAsDown: 3, + StoreAttemptsDays: 7, + } + + if tt.expectSuccess { + var response map[string]string + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/healthcheck-config", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + &response, + ) + assert.Contains(t, response["message"], "successfully") + } else { + testResp := test_utils.MakePostRequest( + t, + router, + "/api/v1/healthcheck-config", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + ) + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_SaveHealthcheckConfig_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + + request := HealthcheckConfigDTO{ + DatabaseID: database.ID, + IsHealthcheckEnabled: true, + IsSentNotificationWhenUnavailable: true, + IntervalMinutes: 5, + AttemptsBeforeConcideredAsDown: 3, + StoreAttemptsDays: 7, + } + + testResp := test_utils.MakePostRequest( + t, + router, + "/api/v1/healthcheck-config", + "Bearer "+nonMember.Token, + request, + http.StatusBadRequest, + ) + + assert.Contains(t, string(testResp.Body), "insufficient permissions") +} + +func Test_GetHealthcheckConfig_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can get healthcheck config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can get healthcheck config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can get healthcheck config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace viewer can get healthcheck config", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can get healthcheck config", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-member cannot get healthcheck config", + workspaceRole: nil, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUserToken = nonMember.Token + } + + if tt.expectSuccess { + var response HealthcheckConfig + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/healthcheck-config/"+database.ID.String(), + "Bearer "+testUserToken, + tt.expectedStatusCode, + &response, + ) + + assert.Equal(t, database.ID, response.DatabaseID) + assert.True(t, response.IsHealthcheckEnabled) + } else { + testResp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/healthcheck-config/"+database.ID.String(), + "Bearer "+testUserToken, + tt.expectedStatusCode, + ) + assert.Contains(t, string(testResp.Body), "insufficient permissions") + } + }) + } +} + +func Test_GetHealthcheckConfig_ReturnsDefaultConfigForNewDatabase(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router) + + var response HealthcheckConfig + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/healthcheck-config/"+database.ID.String(), + "Bearer "+owner.Token, + http.StatusOK, + &response, + ) + + assert.Equal(t, database.ID, response.DatabaseID) + assert.True(t, response.IsHealthcheckEnabled) + assert.True(t, response.IsSentNotificationWhenUnavailable) + assert.Equal(t, 1, response.IntervalMinutes) + assert.Equal(t, 3, response.AttemptsBeforeConcideredAsDown) + assert.Equal(t, 7, response.StoreAttemptsDays) +} + +func createTestDatabaseViaAPI( + name string, + workspaceID uuid.UUID, + token string, + router *gin.Engine, +) *databases.Database { + testDbName := "test_db" + request := databases.Database{ + WorkspaceID: &workspaceID, + Name: name, + Type: databases.DatabaseTypePostgres, + Postgresql: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + Database: &testDbName, + }, + } + + w := workspaces_testing.MakeAPIRequest( + router, + "POST", + "/api/v1/databases/create", + "Bearer "+token, + request, + ) + + if w.Code != http.StatusCreated { + panic("Failed to create database") + } + + var database databases.Database + if err := json.Unmarshal(w.Body.Bytes(), &database); err != nil { + panic(err) + } + return &database +} diff --git a/backend/internal/features/healthcheck/config/di.go b/backend/internal/features/healthcheck/config/di.go index d328d4f..74b129f 100644 --- a/backend/internal/features/healthcheck/config/di.go +++ b/backend/internal/features/healthcheck/config/di.go @@ -1,8 +1,9 @@ package healthcheck_config import ( + "postgresus-backend/internal/features/audit_logs" "postgresus-backend/internal/features/databases" - "postgresus-backend/internal/features/users" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "postgresus-backend/internal/util/logger" ) @@ -10,11 +11,12 @@ var healthcheckConfigRepository = &HealthcheckConfigRepository{} var healthcheckConfigService = &HealthcheckConfigService{ databases.GetDatabaseService(), healthcheckConfigRepository, + workspaces_services.GetWorkspaceService(), + audit_logs.GetAuditLogService(), logger.GetLogger(), } var healthcheckConfigController = &HealthcheckConfigController{ healthcheckConfigService, - users.GetUserService(), } func GetHealthcheckConfigService() *HealthcheckConfigService { diff --git a/backend/internal/features/healthcheck/config/service.go b/backend/internal/features/healthcheck/config/service.go index 02d920f..6cc6b0c 100644 --- a/backend/internal/features/healthcheck/config/service.go +++ b/backend/internal/features/healthcheck/config/service.go @@ -2,9 +2,12 @@ package healthcheck_config import ( "errors" + "fmt" "log/slog" + "postgresus-backend/internal/features/audit_logs" "postgresus-backend/internal/features/databases" users_models "postgresus-backend/internal/features/users/models" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "github.com/google/uuid" ) @@ -12,6 +15,8 @@ import ( type HealthcheckConfigService struct { databaseService *databases.DatabaseService healthcheckConfigRepository *HealthcheckConfigRepository + workspaceService *workspaces_services.WorkspaceService + auditLogService *audit_logs.AuditLogService logger *slog.Logger } @@ -33,8 +38,16 @@ func (s *HealthcheckConfigService) Save( return err } - if database.UserID != user.ID { - return errors.New("user does not have access to this database") + if database.WorkspaceID == nil { + return errors.New("cannot modify healthcheck config for databases without workspace") + } + + canManage, err := s.workspaceService.CanUserManageDBs(*database.WorkspaceID, &user) + if err != nil { + return err + } + if !canManage { + return errors.New("insufficient permissions to modify healthcheck config") } healthcheckConfig := configDTO.ToDTO() @@ -60,6 +73,12 @@ func (s *HealthcheckConfigService) Save( } } + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Healthcheck config updated for database '%s'", database.Name), + &user.ID, + database.WorkspaceID, + ) + return nil } @@ -72,8 +91,16 @@ func (s *HealthcheckConfigService) GetByDatabaseID( return nil, err } - if database.UserID != user.ID { - return nil, errors.New("user does not have access to this database") + if database.WorkspaceID == nil { + return nil, errors.New("cannot access healthcheck config for databases without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, &user) + if err != nil { + return nil, err + } + if !canAccess { + return nil, errors.New("insufficient permissions to view healthcheck config") } config, err := s.healthcheckConfigRepository.GetByDatabaseID(database.ID) diff --git a/backend/internal/features/notifiers/controller.go b/backend/internal/features/notifiers/controller.go index f220900..576e483 100644 --- a/backend/internal/features/notifiers/controller.go +++ b/backend/internal/features/notifiers/controller.go @@ -2,15 +2,16 @@ package notifiers import ( "net/http" - "postgresus-backend/internal/features/users" + users_middleware "postgresus-backend/internal/features/users/middleware" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "github.com/gin-gonic/gin" "github.com/google/uuid" ) type NotifierController struct { - notifierService *NotifierService - userService *users.UserService + notifierService *NotifierService + workspaceService *workspaces_services.WorkspaceService } func (c *NotifierController) RegisterRoutes(router *gin.RouterGroup) { @@ -29,35 +30,45 @@ func (c *NotifierController) RegisterRoutes(router *gin.RouterGroup) { // @Accept json // @Produce json // @Param Authorization header string true "JWT token" -// @Param notifier body Notifier true "Notifier data" +// @Param request body Notifier true "Notifier data with workspaceId" // @Success 200 {object} Notifier // @Failure 400 // @Failure 401 +// @Failure 403 // @Router /notifiers [post] func (c *NotifierController) SaveNotifier(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } - var notifier Notifier - if err := ctx.ShouldBindJSON(¬ifier); err != nil { + var request Notifier + if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := notifier.Validate(); err != nil { + if request.WorkspaceID == uuid.Nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceId is required"}) + return + } + + if err := request.Validate(); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := c.notifierService.SaveNotifier(user, ¬ifier); err != nil { + if err := c.notifierService.SaveNotifier(user, request.WorkspaceID, &request); err != nil { + if err.Error() == "insufficient permissions to manage notifier in this workspace" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - ctx.JSON(http.StatusOK, notifier) + ctx.JSON(http.StatusOK, request) } // GetNotifier @@ -70,11 +81,12 @@ func (c *NotifierController) SaveNotifier(ctx *gin.Context) { // @Success 200 {object} Notifier // @Failure 400 // @Failure 401 +// @Failure 403 // @Router /notifiers/{id} [get] func (c *NotifierController) GetNotifier(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } @@ -86,6 +98,10 @@ 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" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -95,22 +111,41 @@ func (c *NotifierController) GetNotifier(ctx *gin.Context) { // GetNotifiers // @Summary Get all notifiers -// @Description Get all notifiers for the current user +// @Description Get all notifiers for a workspace // @Tags notifiers // @Produce json // @Param Authorization header string true "JWT token" +// @Param workspace_id query string true "Workspace ID" // @Success 200 {array} Notifier +// @Failure 400 // @Failure 401 +// @Failure 403 // @Router /notifiers [get] func (c *NotifierController) GetNotifiers(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } - notifiers, err := c.notifierService.GetNotifiers(user) + workspaceIDStr := ctx.Query("workspace_id") + if workspaceIDStr == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter is required"}) + return + } + + workspaceID, err := uuid.Parse(workspaceIDStr) if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace_id"}) + return + } + + notifiers, err := c.notifierService.GetNotifiers(user, workspaceID) + if err != nil { + if err.Error() == "insufficient permissions to view notifiers in this workspace" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -128,11 +163,12 @@ func (c *NotifierController) GetNotifiers(ctx *gin.Context) { // @Success 200 // @Failure 400 // @Failure 401 +// @Failure 403 // @Router /notifiers/{id} [delete] func (c *NotifierController) DeleteNotifier(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } @@ -142,13 +178,11 @@ func (c *NotifierController) DeleteNotifier(ctx *gin.Context) { return } - notifier, err := c.notifierService.GetNotifier(user, id) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := c.notifierService.DeleteNotifier(user, notifier.ID); err != nil { + if err := c.notifierService.DeleteNotifier(user, id); err != nil { + if err.Error() == "insufficient permissions to manage notifier in this workspace" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -166,11 +200,12 @@ func (c *NotifierController) DeleteNotifier(ctx *gin.Context) { // @Success 200 // @Failure 400 // @Failure 401 +// @Failure 403 // @Router /notifiers/{id}/test [post] func (c *NotifierController) SendTestNotification(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } @@ -181,6 +216,10 @@ 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" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -195,28 +234,44 @@ func (c *NotifierController) SendTestNotification(ctx *gin.Context) { // @Accept json // @Produce json // @Param Authorization header string true "JWT token" -// @Param notifier body Notifier true "Notifier data" +// @Param request body Notifier true "Notifier data with workspaceId" // @Success 200 // @Failure 400 // @Failure 401 +// @Failure 403 // @Router /notifiers/direct-test [post] func (c *NotifierController) SendTestNotificationDirect(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } - var notifier Notifier - if err := ctx.ShouldBindJSON(¬ifier); err != nil { + var request Notifier + if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // For direct test, associate with the current user - notifier.UserID = user.ID + if request.WorkspaceID == uuid.Nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceId is required"}) + return + } - if err := c.notifierService.SendTestNotificationToNotifier(¬ifier); err != nil { + canView, _, err := c.workspaceService.CanUserAccessWorkspace(request.WorkspaceID, user) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !canView { + ctx.JSON( + http.StatusForbidden, + gin.H{"error": "insufficient permissions to test notifier in this workspace"}, + ) + return + } + + if err := c.notifierService.SendTestNotificationToNotifier(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/backend/internal/features/notifiers/controller_test.go b/backend/internal/features/notifiers/controller_test.go new file mode 100644 index 0000000..e2441af --- /dev/null +++ b/backend/internal/features/notifiers/controller_test.go @@ -0,0 +1,514 @@ +package notifiers + +import ( + "fmt" + "net/http" + "testing" + + "postgresus-backend/internal/config" + audit_logs "postgresus-backend/internal/features/audit_logs" + telegram_notifier "postgresus-backend/internal/features/notifiers/models/telegram" + webhook_notifier "postgresus-backend/internal/features/notifiers/models/webhook" + users_enums "postgresus-backend/internal/features/users/enums" + users_middleware "postgresus-backend/internal/features/users/middleware" + users_services "postgresus-backend/internal/features/users/services" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func Test_SaveNewNotifier_NotifierReturnedViaGet(t *testing.T) { + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + notifier := createNewNotifier(workspace.ID) + + var savedNotifier Notifier + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/notifiers", + "Bearer "+owner.Token, + *notifier, + http.StatusOK, + &savedNotifier, + ) + + verifyNotifierData(t, notifier, &savedNotifier) + assert.NotEmpty(t, savedNotifier.ID) + + // Verify notifier is returned via GET + var retrievedNotifier Notifier + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/notifiers/%s", savedNotifier.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + &retrievedNotifier, + ) + + verifyNotifierData(t, &savedNotifier, &retrievedNotifier) + + // Verify notifier is returned via GET all notifiers + var notifiers []Notifier + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/notifiers?workspace_id=%s", workspace.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + ¬ifiers, + ) + + assert.Len(t, notifiers, 1) + + deleteNotifier(t, router, savedNotifier.ID, workspace.ID, owner.Token) + workspaces_testing.RemoveTestWorkspace(workspace, router) +} + +func Test_UpdateExistingNotifier_UpdatedNotifierReturnedViaGet(t *testing.T) { + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + notifier := createNewNotifier(workspace.ID) + + var savedNotifier Notifier + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/notifiers", + "Bearer "+owner.Token, + *notifier, + http.StatusOK, + &savedNotifier, + ) + + updatedName := "Updated Notifier " + uuid.New().String() + savedNotifier.Name = updatedName + + var updatedNotifier Notifier + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/notifiers", + "Bearer "+owner.Token, + savedNotifier, + http.StatusOK, + &updatedNotifier, + ) + + assert.Equal(t, updatedName, updatedNotifier.Name) + assert.Equal(t, savedNotifier.ID, updatedNotifier.ID) + + deleteNotifier(t, router, updatedNotifier.ID, workspace.ID, owner.Token) + workspaces_testing.RemoveTestWorkspace(workspace, router) +} + +func Test_DeleteNotifier_NotifierNotReturnedViaGet(t *testing.T) { + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + notifier := createNewNotifier(workspace.ID) + + var savedNotifier Notifier + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/notifiers", + "Bearer "+owner.Token, + *notifier, + http.StatusOK, + &savedNotifier, + ) + + test_utils.MakeDeleteRequest( + t, + router, + fmt.Sprintf("/api/v1/notifiers/%s", savedNotifier.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + ) + + response := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/notifiers/%s", savedNotifier.ID.String()), + "Bearer "+owner.Token, + http.StatusBadRequest, + ) + + assert.Contains(t, string(response.Body), "error") + workspaces_testing.RemoveTestWorkspace(workspace, router) +} + +func Test_SendTestNotificationDirect_NotificationSent(t *testing.T) { + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + notifier := createTelegramNotifier(workspace.ID) + + response := test_utils.MakePostRequest( + t, router, "/api/v1/notifiers/direct-test", "Bearer "+owner.Token, *notifier, http.StatusOK, + ) + + assert.Contains(t, string(response.Body), "successful") + workspaces_testing.RemoveTestWorkspace(workspace, router) +} + +func Test_SendTestNotificationExisting_NotificationSent(t *testing.T) { + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + notifier := createTelegramNotifier(workspace.ID) + + var savedNotifier Notifier + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/notifiers", + "Bearer "+owner.Token, + *notifier, + http.StatusOK, + &savedNotifier, + ) + + response := test_utils.MakePostRequest( + t, + router, + fmt.Sprintf("/api/v1/notifiers/%s/test", savedNotifier.ID.String()), + "Bearer "+owner.Token, + nil, + http.StatusOK, + ) + + assert.Contains(t, string(response.Body), "successful") + + deleteNotifier(t, router, savedNotifier.ID, workspace.ID, owner.Token) + 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, + ) + + notifier := createNewNotifier(workspace.ID) + + var savedNotifier Notifier + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/notifiers", + "Bearer "+owner.Token, + *notifier, + http.StatusOK, + &savedNotifier, + ) + + // 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) + + // Viewer cannot CREATE notifier + newNotifier := createNewNotifier(workspace.ID) + test_utils.MakePostRequest( + t, router, "/api/v1/notifiers", "Bearer "+viewer.Token, *newNotifier, http.StatusForbidden, + ) + + // Viewer cannot UPDATE notifier + savedNotifier.Name = "Updated by viewer" + test_utils.MakePostRequest( + t, router, "/api/v1/notifiers", "Bearer "+viewer.Token, savedNotifier, http.StatusForbidden, + ) + + // Viewer cannot DELETE notifier + test_utils.MakeDeleteRequest( + t, + router, + fmt.Sprintf("/api/v1/notifiers/%s", savedNotifier.ID.String()), + "Bearer "+viewer.Token, + http.StatusForbidden, + ) + + deleteNotifier(t, router, savedNotifier.ID, workspace.ID, owner.Token) + workspaces_testing.RemoveTestWorkspace(workspace, router) +} + +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, + ) + + 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) +} + +func Test_UserNotInWorkspace_CannotAccessNotifiers(t *testing.T) { + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + outsider := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + notifier := createNewNotifier(workspace.ID) + + var savedNotifier Notifier + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/notifiers", + "Bearer "+owner.Token, + *notifier, + http.StatusOK, + &savedNotifier, + ) + + // Outsider cannot GET notifiers + test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/notifiers?workspace_id=%s", workspace.ID.String()), + "Bearer "+outsider.Token, + http.StatusForbidden, + ) + + // Outsider cannot CREATE notifier + test_utils.MakePostRequest( + t, router, "/api/v1/notifiers", "Bearer "+outsider.Token, *notifier, http.StatusForbidden, + ) + + // Outsider cannot UPDATE notifier + test_utils.MakePostRequest( + t, + router, + "/api/v1/notifiers", + "Bearer "+outsider.Token, + savedNotifier, + http.StatusForbidden, + ) + + // Outsider cannot DELETE notifier + test_utils.MakeDeleteRequest( + t, + router, + fmt.Sprintf("/api/v1/notifiers/%s", savedNotifier.ID.String()), + "Bearer "+outsider.Token, + http.StatusForbidden, + ) + + deleteNotifier(t, router, savedNotifier.ID, workspace.ID, owner.Token) + workspaces_testing.RemoveTestWorkspace(workspace, router) +} + +func Test_CrossWorkspaceSecurity_CannotAccessNotifierFromAnotherWorkspace(t *testing.T) { + owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember) + owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace1 := workspaces_testing.CreateTestWorkspace("Workspace 1", owner1, router) + workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router) + + notifier1 := createNewNotifier(workspace1.ID) + + var savedNotifier Notifier + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/notifiers", + "Bearer "+owner1.Token, + *notifier1, + http.StatusOK, + &savedNotifier, + ) + + // Try to access workspace1's notifier with owner2 from workspace2 + response := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/notifiers/%s", savedNotifier.ID.String()), + "Bearer "+owner2.Token, + http.StatusForbidden, + ) + assert.Contains(t, string(response.Body), "insufficient permissions") + + deleteNotifier(t, router, savedNotifier.ID, workspace1.ID, owner1.Token) + workspaces_testing.RemoveTestWorkspace(workspace1, router) + workspaces_testing.RemoveTestWorkspace(workspace2, router) +} + +func createRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + + v1 := router.Group("/api/v1") + protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService())) + + if routerGroup, ok := protected.(*gin.RouterGroup); ok { + GetNotifierController().RegisterRoutes(routerGroup) + workspaces_controllers.GetWorkspaceController().RegisterRoutes(routerGroup) + workspaces_controllers.GetMembershipController().RegisterRoutes(routerGroup) + } + + audit_logs.SetupDependencies() + + return router +} + +func createNewNotifier(workspaceID uuid.UUID) *Notifier { + return &Notifier{ + WorkspaceID: workspaceID, + Name: "Test Notifier " + uuid.New().String(), + NotifierType: NotifierTypeWebhook, + WebhookNotifier: &webhook_notifier.WebhookNotifier{ + WebhookURL: "https://webhook.site/test-" + uuid.New().String(), + WebhookMethod: webhook_notifier.WebhookMethodPOST, + }, + } +} + +func createTelegramNotifier(workspaceID uuid.UUID) *Notifier { + env := config.GetEnv() + return &Notifier{ + WorkspaceID: workspaceID, + Name: "Test Telegram Notifier " + uuid.New().String(), + NotifierType: NotifierTypeTelegram, + TelegramNotifier: &telegram_notifier.TelegramNotifier{ + BotToken: env.TestTelegramBotToken, + TargetChatID: env.TestTelegramChatID, + }, + } +} + +func verifyNotifierData(t *testing.T, expected *Notifier, actual *Notifier) { + assert.Equal(t, expected.Name, actual.Name) + assert.Equal(t, expected.NotifierType, actual.NotifierType) + assert.Equal(t, expected.WorkspaceID, actual.WorkspaceID) +} + +func deleteNotifier( + t *testing.T, + router *gin.Engine, + notifierID, workspaceID uuid.UUID, + token string, +) { + test_utils.MakeDeleteRequest( + t, + router, + fmt.Sprintf("/api/v1/notifiers/%s", notifierID.String()), + "Bearer "+token, + http.StatusOK, + ) +} diff --git a/backend/internal/features/notifiers/di.go b/backend/internal/features/notifiers/di.go index e063371..ab7bb11 100644 --- a/backend/internal/features/notifiers/di.go +++ b/backend/internal/features/notifiers/di.go @@ -1,7 +1,8 @@ package notifiers import ( - "postgresus-backend/internal/features/users" + audit_logs "postgresus-backend/internal/features/audit_logs" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "postgresus-backend/internal/util/logger" ) @@ -9,10 +10,12 @@ var notifierRepository = &NotifierRepository{} var notifierService = &NotifierService{ notifierRepository, logger.GetLogger(), + workspaces_services.GetWorkspaceService(), + audit_logs.GetAuditLogService(), } var notifierController = &NotifierController{ notifierService, - users.GetUserService(), + workspaces_services.GetWorkspaceService(), } func GetNotifierController() *NotifierController { @@ -22,3 +25,7 @@ func GetNotifierController() *NotifierController { func GetNotifierService() *NotifierService { return notifierService } + +func SetupDependencies() { + workspaces_services.GetWorkspaceService().AddWorkspaceDeletionListener(notifierService) +} diff --git a/backend/internal/features/notifiers/model.go b/backend/internal/features/notifiers/model.go index 870a524..0145790 100644 --- a/backend/internal/features/notifiers/model.go +++ b/backend/internal/features/notifiers/model.go @@ -15,7 +15,7 @@ import ( type Notifier struct { ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"` - UserID uuid.UUID `json:"userId" gorm:"column:user_id;not null;type:uuid;index"` + WorkspaceID uuid.UUID `json:"workspaceId" gorm:"column:workspace_id;not null;type:uuid;index"` Name string `json:"name" gorm:"column:name;not null;type:varchar(255)"` NotifierType NotifierType `json:"notifierType" gorm:"column:notifier_type;not null;type:varchar(50)"` LastSendError *string `json:"lastSendError" gorm:"column:last_send_error;type:text"` diff --git a/backend/internal/features/notifiers/repository.go b/backend/internal/features/notifiers/repository.go index 2d18a71..c055909 100644 --- a/backend/internal/features/notifiers/repository.go +++ b/backend/internal/features/notifiers/repository.go @@ -143,7 +143,7 @@ func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) { return ¬ifier, nil } -func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error) { +func (r *NotifierRepository) FindByWorkspaceID(workspaceID uuid.UUID) ([]*Notifier, error) { var notifiers []*Notifier if err := storage. @@ -154,7 +154,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error) Preload("SlackNotifier"). Preload("DiscordNotifier"). Preload("TeamsNotifier"). - Where("user_id = ?", userID). + Where("workspace_id = ?", workspaceID). Order("name ASC"). Find(¬ifiers).Error; err != nil { return nil, err diff --git a/backend/internal/features/notifiers/service.go b/backend/internal/features/notifiers/service.go index e7a2824..1f7f54a 100644 --- a/backend/internal/features/notifiers/service.go +++ b/backend/internal/features/notifiers/service.go @@ -2,8 +2,12 @@ package notifiers import ( "errors" + "fmt" "log/slog" + + audit_logs "postgresus-backend/internal/features/audit_logs" users_models "postgresus-backend/internal/features/users/models" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "github.com/google/uuid" ) @@ -11,32 +15,59 @@ import ( type NotifierService struct { notifierRepository *NotifierRepository logger *slog.Logger + workspaceService *workspaces_services.WorkspaceService + auditLogService *audit_logs.AuditLogService } func (s *NotifierService) SaveNotifier( user *users_models.User, + workspaceID uuid.UUID, notifier *Notifier, ) error { - if notifier.ID != uuid.Nil { + canManage, err := s.workspaceService.CanUserManageDBs(workspaceID, user) + if err != nil { + return err + } + if !canManage { + return errors.New("insufficient permissions to manage notifier in this workspace") + } + + isUpdate := notifier.ID != uuid.Nil + + if isUpdate { existingNotifier, err := s.notifierRepository.FindByID(notifier.ID) if err != nil { return err } - if existingNotifier.UserID != user.ID { - return errors.New("you have not access to this notifier") + if existingNotifier.WorkspaceID != workspaceID { + return errors.New("notifier does not belong to this workspace") } - notifier.UserID = existingNotifier.UserID + notifier.WorkspaceID = existingNotifier.WorkspaceID } else { - notifier.UserID = user.ID + notifier.WorkspaceID = workspaceID } - _, err := s.notifierRepository.Save(notifier) + _, err = s.notifierRepository.Save(notifier) if err != nil { return err } + if isUpdate { + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Notifier updated: %s", notifier.Name), + &user.ID, + &workspaceID, + ) + } else { + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Notifier created: %s", notifier.Name), + &user.ID, + &workspaceID, + ) + } + return nil } @@ -49,11 +80,26 @@ func (s *NotifierService) DeleteNotifier( return err } - if notifier.UserID != user.ID { - return errors.New("you have not access to this notifier") + canManage, err := s.workspaceService.CanUserManageDBs(notifier.WorkspaceID, user) + if err != nil { + return err + } + if !canManage { + return errors.New("insufficient permissions to manage notifier in this workspace") } - return s.notifierRepository.Delete(notifier) + err = s.notifierRepository.Delete(notifier) + if err != nil { + return err + } + + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Notifier deleted: %s", notifier.Name), + &user.ID, + ¬ifier.WorkspaceID, + ) + + return nil } func (s *NotifierService) GetNotifier( @@ -65,8 +111,12 @@ func (s *NotifierService) GetNotifier( return nil, err } - if notifier.UserID != user.ID { - return nil, errors.New("you have not access to this notifier") + canView, _, err := s.workspaceService.CanUserAccessWorkspace(notifier.WorkspaceID, user) + if err != nil { + return nil, err + } + if !canView { + return nil, errors.New("insufficient permissions to view notifier in this workspace") } return notifier, nil @@ -74,8 +124,17 @@ func (s *NotifierService) GetNotifier( func (s *NotifierService) GetNotifiers( user *users_models.User, + workspaceID uuid.UUID, ) ([]*Notifier, error) { - return s.notifierRepository.FindByUserID(user.ID) + canView, _, err := s.workspaceService.CanUserAccessWorkspace(workspaceID, user) + if err != nil { + return nil, err + } + if !canView { + return nil, errors.New("insufficient permissions to view notifiers in this workspace") + } + + return s.notifierRepository.FindByWorkspaceID(workspaceID) } func (s *NotifierService) SendTestNotification( @@ -87,8 +146,12 @@ func (s *NotifierService) SendTestNotification( return err } - if notifier.UserID != user.ID { - return errors.New("you have not access to this notifier") + canView, _, err := s.workspaceService.CanUserAccessWorkspace(notifier.WorkspaceID, user) + if err != nil { + return err + } + if !canView { + return errors.New("insufficient permissions to test notifier in this workspace") } err = notifier.Send(s.logger, "Test message", "This is a test message") @@ -143,3 +206,18 @@ func (s *NotifierService) SendNotification( s.logger.Error("Failed to save notifier", "error", err) } } + +func (s *NotifierService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error { + notifiers, err := s.notifierRepository.FindByWorkspaceID(workspaceID) + if err != nil { + return fmt.Errorf("failed to get notifiers for workspace deletion: %w", err) + } + + for _, notifier := range notifiers { + if err := s.notifierRepository.Delete(notifier); err != nil { + return fmt.Errorf("failed to delete notifier %s: %w", notifier.ID, err) + } + } + + return nil +} diff --git a/backend/internal/features/notifiers/testing.go b/backend/internal/features/notifiers/testing.go index 4b68680..a2fc7cb 100644 --- a/backend/internal/features/notifiers/testing.go +++ b/backend/internal/features/notifiers/testing.go @@ -6,9 +6,9 @@ import ( "github.com/google/uuid" ) -func CreateTestNotifier(userID uuid.UUID) *Notifier { +func CreateTestNotifier(workspaceID uuid.UUID) *Notifier { notifier := &Notifier{ - UserID: userID, + WorkspaceID: workspaceID, Name: "test " + uuid.New().String(), NotifierType: NotifierTypeWebhook, WebhookNotifier: &webhook_notifier.WebhookNotifier{ diff --git a/backend/internal/features/restores/controller.go b/backend/internal/features/restores/controller.go index 9eca353..f64ec37 100644 --- a/backend/internal/features/restores/controller.go +++ b/backend/internal/features/restores/controller.go @@ -2,7 +2,7 @@ package restores import ( "net/http" - "postgresus-backend/internal/features/users" + users_middleware "postgresus-backend/internal/features/users/middleware" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -10,7 +10,6 @@ import ( type RestoreController struct { restoreService *RestoreService - userService *users.UserService } func (c *RestoreController) RegisterRoutes(router *gin.RouterGroup) { @@ -29,24 +28,18 @@ func (c *RestoreController) RegisterRoutes(router *gin.RouterGroup) { // @Failure 401 // @Router /restores/{backupId} [get] func (c *RestoreController) GetRestores(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + backupID, err := uuid.Parse(ctx.Param("backupId")) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"}) return } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - restores, err := c.restoreService.GetRestores(user, backupID) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -66,6 +59,12 @@ func (c *RestoreController) GetRestores(ctx *gin.Context) { // @Failure 401 // @Router /restores/{backupId}/restore [post] func (c *RestoreController) RestoreBackup(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + backupID, err := uuid.Parse(ctx.Param("backupId")) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"}) @@ -78,18 +77,6 @@ func (c *RestoreController) RestoreBackup(ctx *gin.Context) { return } - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) - return - } - - user, err := c.userService.GetUserFromToken(authorizationHeader) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - if err := c.restoreService.RestoreBackupWithAuth(user, backupID, requestDTO); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/backend/internal/features/restores/controller_test.go b/backend/internal/features/restores/controller_test.go new file mode 100644 index 0000000..d758a2a --- /dev/null +++ b/backend/internal/features/restores/controller_test.go @@ -0,0 +1,348 @@ +package restores + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + audit_logs "postgresus-backend/internal/features/audit_logs" + "postgresus-backend/internal/features/backups/backups" + backups_config "postgresus-backend/internal/features/backups/config" + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/databases/databases/postgresql" + "postgresus-backend/internal/features/restores/models" + "postgresus-backend/internal/features/storages" + local_storage "postgresus-backend/internal/features/storages/models/local" + users_dto "postgresus-backend/internal/features/users/dto" + users_enums "postgresus-backend/internal/features/users/enums" + users_services "postgresus-backend/internal/features/users/services" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers" + workspaces_models "postgresus-backend/internal/features/workspaces/models" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + "postgresus-backend/internal/util/tools" +) + +func createTestRouter() *gin.Engine { + router := workspaces_testing.CreateTestRouter( + workspaces_controllers.GetWorkspaceController(), + workspaces_controllers.GetMembershipController(), + databases.GetDatabaseController(), + backups_config.GetBackupConfigController(), + backups.GetBackupController(), + GetRestoreController(), + ) + return router +} + +func Test_GetRestores_WhenUserIsWorkspaceMember_RestoresReturned(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router) + + var restores []*models.Restore + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/restores/%s", backup.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + &restores, + ) + + assert.NotNil(t, restores) + assert.Equal(t, 0, len(restores)) + assert.NotNil(t, database) +} + +func Test_GetRestores_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + _, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router) + + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + + testResp := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/restores/%s", backup.ID.String()), + "Bearer "+nonMember.Token, + http.StatusBadRequest, + ) + + assert.Contains(t, string(testResp.Body), "insufficient permissions") +} + +func Test_GetRestores_WhenUserIsGlobalAdmin_RestoresReturned(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + _, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router) + + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + var restores []*models.Restore + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/restores/%s", backup.ID.String()), + "Bearer "+admin.Token, + http.StatusOK, + &restores, + ) + + assert.NotNil(t, restores) +} + +func Test_RestoreBackup_WhenUserIsWorkspaceMember_RestoreInitiated(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + _, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router) + + request := RestoreBackupRequest{ + PostgresqlDatabase: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + }, + } + + testResp := test_utils.MakePostRequest( + t, + router, + fmt.Sprintf("/api/v1/restores/%s/restore", backup.ID.String()), + "Bearer "+owner.Token, + request, + http.StatusOK, + ) + + assert.Contains(t, string(testResp.Body), "restore started successfully") +} + +func Test_RestoreBackup_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + _, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router) + + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + + request := RestoreBackupRequest{ + PostgresqlDatabase: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + }, + } + + testResp := test_utils.MakePostRequest( + t, + router, + fmt.Sprintf("/api/v1/restores/%s/restore", backup.ID.String()), + "Bearer "+nonMember.Token, + request, + http.StatusBadRequest, + ) + + assert.Contains(t, string(testResp.Body), "insufficient permissions") +} + +func Test_RestoreBackup_AuditLogWritten(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router) + + request := RestoreBackupRequest{ + PostgresqlDatabase: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + }, + } + + test_utils.MakePostRequest( + t, + router, + fmt.Sprintf("/api/v1/restores/%s/restore", backup.ID.String()), + "Bearer "+owner.Token, + request, + http.StatusOK, + ) + + time.Sleep(100 * time.Millisecond) + + auditLogService := audit_logs.GetAuditLogService() + auditLogs, err := auditLogService.GetWorkspaceAuditLogs( + workspace.ID, + &audit_logs.GetAuditLogsRequest{ + Limit: 100, + Offset: 0, + }, + ) + assert.NoError(t, err) + + found := false + for _, log := range auditLogs.AuditLogs { + if strings.Contains(log.Message, "Database restored from backup") && + strings.Contains(log.Message, database.Name) { + found = true + break + } + } + assert.True(t, found, "Audit log for restore not found") +} + +func createTestDatabaseWithBackupForRestore( + workspace *workspaces_models.Workspace, + owner *users_dto.SignInResponseDTO, + router *gin.Engine, +) (*databases.Database, *backups.Backup) { + database := createTestDatabase("Test Database", workspace.ID, owner.Token, router) + storage := createTestStorage(workspace.ID) + + configService := backups_config.GetBackupConfigService() + config, err := configService.GetBackupConfigByDbId(database.ID) + if err != nil { + panic(err) + } + + config.IsBackupsEnabled = true + config.StorageID = &storage.ID + config.Storage = storage + _, err = configService.SaveBackupConfig(config) + if err != nil { + panic(err) + } + + backup := createTestBackup(database, owner) + + return database, backup +} + +func createTestDatabase( + name string, + workspaceID uuid.UUID, + token string, + router *gin.Engine, +) *databases.Database { + testDbName := "test_db" + request := databases.Database{ + WorkspaceID: &workspaceID, + Name: name, + Type: databases.DatabaseTypePostgres, + Postgresql: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "postgres", + Database: &testDbName, + }, + } + + w := workspaces_testing.MakeAPIRequest( + router, + "POST", + "/api/v1/databases/create", + "Bearer "+token, + request, + ) + + if w.Code != http.StatusCreated { + panic( + fmt.Sprintf("Failed to create database. Status: %d, Body: %s", w.Code, w.Body.String()), + ) + } + + var database databases.Database + if err := json.Unmarshal(w.Body.Bytes(), &database); err != nil { + panic(err) + } + + return &database +} + +func createTestStorage(workspaceID uuid.UUID) *storages.Storage { + storage := &storages.Storage{ + WorkspaceID: workspaceID, + Type: storages.StorageTypeLocal, + Name: "Test Storage " + uuid.New().String(), + LocalStorage: &local_storage.LocalStorage{}, + } + + repo := &storages.StorageRepository{} + storage, err := repo.Save(storage) + if err != nil { + panic(err) + } + + return storage +} + +func createTestBackup( + database *databases.Database, + owner *users_dto.SignInResponseDTO, +) *backups.Backup { + userService := users_services.GetUserService() + user, err := userService.GetUserFromToken(owner.Token) + if err != nil { + panic(err) + } + + storages, err := storages.GetStorageService().GetStorages(user, *database.WorkspaceID) + if err != nil || len(storages) == 0 { + panic("No storage found for workspace") + } + + backup := &backups.Backup{ + ID: uuid.New(), + DatabaseID: database.ID, + Database: database, + StorageID: storages[0].ID, + Storage: storages[0], + Status: backups.BackupStatusCompleted, + BackupSizeMb: 10.5, + BackupDurationMs: 1000, + CreatedAt: time.Now().UTC(), + } + + repo := &backups.BackupRepository{} + if err := repo.Save(backup); err != nil { + panic(err) + } + + dummyContent := []byte("dummy backup content for testing") + reader := strings.NewReader(string(dummyContent)) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if err := storages[0].SaveFile(logger, backup.ID, reader); err != nil { + panic(fmt.Sprintf("Failed to create test backup file: %v", err)) + } + + return backup +} diff --git a/backend/internal/features/restores/di.go b/backend/internal/features/restores/di.go index 157fb16..900c29b 100644 --- a/backend/internal/features/restores/di.go +++ b/backend/internal/features/restores/di.go @@ -1,12 +1,13 @@ package restores import ( + audit_logs "postgresus-backend/internal/features/audit_logs" "postgresus-backend/internal/features/backups/backups" backups_config "postgresus-backend/internal/features/backups/config" "postgresus-backend/internal/features/databases" "postgresus-backend/internal/features/restores/usecases" "postgresus-backend/internal/features/storages" - "postgresus-backend/internal/features/users" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "postgresus-backend/internal/util/logger" ) @@ -19,10 +20,11 @@ var restoreService = &RestoreService{ usecases.GetRestoreBackupUsecase(), databases.GetDatabaseService(), logger.GetLogger(), + workspaces_services.GetWorkspaceService(), + audit_logs.GetAuditLogService(), } var restoreController = &RestoreController{ restoreService, - users.GetUserService(), } var restoreBackgroundService = &RestoreBackgroundService{ diff --git a/backend/internal/features/restores/service.go b/backend/internal/features/restores/service.go index 5327be9..755e4be 100644 --- a/backend/internal/features/restores/service.go +++ b/backend/internal/features/restores/service.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log/slog" + audit_logs "postgresus-backend/internal/features/audit_logs" "postgresus-backend/internal/features/backups/backups" backups_config "postgresus-backend/internal/features/backups/config" "postgresus-backend/internal/features/databases" @@ -12,6 +13,7 @@ import ( "postgresus-backend/internal/features/restores/usecases" "postgresus-backend/internal/features/storages" users_models "postgresus-backend/internal/features/users/models" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "postgresus-backend/internal/util/tools" "time" @@ -26,6 +28,8 @@ type RestoreService struct { restoreBackupUsecase *usecases.RestoreBackupUsecase databaseService *databases.DatabaseService logger *slog.Logger + workspaceService *workspaces_services.WorkspaceService + auditLogService *audit_logs.AuditLogService } func (s *RestoreService) OnBeforeBackupRemove(backup *backups.Backup) error { @@ -58,8 +62,19 @@ func (s *RestoreService) GetRestores( return nil, err } - if backup.Database.UserID != user.ID { - return nil, errors.New("user does not have access to this backup") + if backup.Database.WorkspaceID == nil { + return nil, errors.New("cannot get restores for database without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace( + *backup.Database.WorkspaceID, + user, + ) + if err != nil { + return nil, err + } + if !canAccess { + return nil, errors.New("insufficient permissions to access restores for this backup") } return s.restoreRepository.FindByBackupID(backupID) @@ -75,8 +90,19 @@ func (s *RestoreService) RestoreBackupWithAuth( return err } - if backup.Database.UserID != user.ID { - return errors.New("user does not have access to this backup") + if backup.Database.WorkspaceID == nil { + return errors.New("cannot restore backup for database without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace( + *backup.Database.WorkspaceID, + user, + ) + if err != nil { + return err + } + if !canAccess { + return errors.New("insufficient permissions to restore this backup") } backupDatabase, err := s.databaseService.GetDatabase(user, backup.DatabaseID) @@ -105,6 +131,16 @@ func (s *RestoreService) RestoreBackupWithAuth( } }() + s.auditLogService.WriteAuditLog( + fmt.Sprintf( + "Database restored from backup %s for database: %s", + backupID.String(), + backup.Database.Name, + ), + &user.ID, + backup.Database.WorkspaceID, + ) + return nil } diff --git a/backend/internal/features/storages/controller.go b/backend/internal/features/storages/controller.go index 09c687f..998106d 100644 --- a/backend/internal/features/storages/controller.go +++ b/backend/internal/features/storages/controller.go @@ -2,15 +2,16 @@ package storages import ( "net/http" - "postgresus-backend/internal/features/users" + users_middleware "postgresus-backend/internal/features/users/middleware" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "github.com/gin-gonic/gin" "github.com/google/uuid" ) type StorageController struct { - storageService *StorageService - userService *users.UserService + storageService *StorageService + workspaceService *workspaces_services.WorkspaceService } func (c *StorageController) RegisterRoutes(router *gin.RouterGroup) { @@ -29,35 +30,45 @@ func (c *StorageController) RegisterRoutes(router *gin.RouterGroup) { // @Accept json // @Produce json // @Param Authorization header string true "JWT token" -// @Param storage body Storage true "Storage data" +// @Param request body Storage true "Storage data with workspaceId" // @Success 200 {object} Storage // @Failure 400 // @Failure 401 +// @Failure 403 // @Router /storages [post] func (c *StorageController) SaveStorage(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } - var storage Storage - if err := ctx.ShouldBindJSON(&storage); err != nil { + var request Storage + if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := storage.Validate(); err != nil { + if request.WorkspaceID == uuid.Nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceId is required"}) + return + } + + if err := request.Validate(); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := c.storageService.SaveStorage(user, &storage); err != nil { + if err := c.storageService.SaveStorage(user, request.WorkspaceID, &request); err != nil { + if err.Error() == "insufficient permissions to manage storage in this workspace" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - ctx.JSON(http.StatusOK, storage) + ctx.JSON(http.StatusOK, request) } // GetStorage @@ -70,11 +81,12 @@ func (c *StorageController) SaveStorage(ctx *gin.Context) { // @Success 200 {object} Storage // @Failure 400 // @Failure 401 +// @Failure 403 // @Router /storages/{id} [get] func (c *StorageController) GetStorage(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } @@ -86,6 +98,10 @@ 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" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -95,22 +111,41 @@ func (c *StorageController) GetStorage(ctx *gin.Context) { // GetStorages // @Summary Get all storages -// @Description Get all storages for the current user +// @Description Get all storages for a workspace // @Tags storages // @Produce json // @Param Authorization header string true "JWT token" +// @Param workspace_id query string true "Workspace ID" // @Success 200 {array} Storage +// @Failure 400 // @Failure 401 +// @Failure 403 // @Router /storages [get] func (c *StorageController) GetStorages(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } - storages, err := c.storageService.GetStorages(user) + workspaceIDStr := ctx.Query("workspace_id") + if workspaceIDStr == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter is required"}) + return + } + + workspaceID, err := uuid.Parse(workspaceIDStr) if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace_id"}) + return + } + + storages, err := c.storageService.GetStorages(user, workspaceID) + if err != nil { + if err.Error() == "insufficient permissions to view storages in this workspace" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -128,11 +163,12 @@ func (c *StorageController) GetStorages(ctx *gin.Context) { // @Success 200 // @Failure 400 // @Failure 401 +// @Failure 403 // @Router /storages/{id} [delete] func (c *StorageController) DeleteStorage(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } @@ -143,6 +179,10 @@ 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" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -160,11 +200,12 @@ func (c *StorageController) DeleteStorage(ctx *gin.Context) { // @Success 200 // @Failure 400 // @Failure 401 +// @Failure 403 // @Router /storages/{id}/test [post] func (c *StorageController) TestStorageConnection(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) - if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } @@ -175,6 +216,10 @@ 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" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -189,33 +234,49 @@ func (c *StorageController) TestStorageConnection(ctx *gin.Context) { // @Accept json // @Produce json // @Param Authorization header string true "JWT token" -// @Param storage body Storage true "Storage data" +// @Param request body Storage true "Storage data with workspaceId" // @Success 200 // @Failure 400 // @Failure 401 +// @Failure 403 // @Router /storages/direct-test [post] func (c *StorageController) TestStorageConnectionDirect(ctx *gin.Context) { - user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var request Storage + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if request.WorkspaceID == uuid.Nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceId is required"}) + return + } + + canView, _, err := c.workspaceService.CanUserAccessWorkspace(request.WorkspaceID, user) if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !canView { + ctx.JSON( + http.StatusForbidden, + gin.H{"error": "insufficient permissions to test storage in this workspace"}, + ) return } - var storage Storage - if err := ctx.ShouldBindJSON(&storage); err != nil { + if err := request.Validate(); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // For direct test, associate with the current user - storage.UserID = user.ID - - if err := storage.Validate(); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := c.storageService.TestStorageConnectionDirect(&storage); err != nil { + if err := c.storageService.TestStorageConnectionDirect(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/backend/internal/features/storages/controller_test.go b/backend/internal/features/storages/controller_test.go index 40bb538..e6fb8b9 100644 --- a/backend/internal/features/storages/controller_test.go +++ b/backend/internal/features/storages/controller_test.go @@ -1,25 +1,40 @@ package storages import ( + "fmt" "net/http" - local_storage "postgresus-backend/internal/features/storages/models/local" - "postgresus-backend/internal/features/users" - test_utils "postgresus-backend/internal/util/testing" "testing" + audit_logs "postgresus-backend/internal/features/audit_logs" + local_storage "postgresus-backend/internal/features/storages/models/local" + users_enums "postgresus-backend/internal/features/users/enums" + users_middleware "postgresus-backend/internal/features/users/middleware" + users_services "postgresus-backend/internal/features/users/services" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/assert" ) func Test_SaveNewStorage_StorageReturnedViaGet(t *testing.T) { - user := users.GetTestUser() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) router := createRouter() - storage := createNewStorage(user.UserID) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + storage := createNewStorage(workspace.ID) var savedStorage Storage test_utils.MakePostRequestAndUnmarshal( - t, router, "/api/v1/storages", user.Token, storage, http.StatusOK, &savedStorage, + t, + router, + "/api/v1/storages", + "Bearer "+owner.Token, + *storage, + http.StatusOK, + &savedStorage, ) verifyStorageData(t, storage, &savedStorage) @@ -30,8 +45,8 @@ func Test_SaveNewStorage_StorageReturnedViaGet(t *testing.T) { test_utils.MakeGetRequestAndUnmarshal( t, router, - "/api/v1/storages/"+savedStorage.ID.String(), - user.Token, + fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()), + "Bearer "+owner.Token, http.StatusOK, &retrievedStorage, ) @@ -41,181 +56,408 @@ func Test_SaveNewStorage_StorageReturnedViaGet(t *testing.T) { // Verify storage is returned via GET all storages var storages []Storage test_utils.MakeGetRequestAndUnmarshal( - t, router, "/api/v1/storages", user.Token, http.StatusOK, &storages, + t, + router, + fmt.Sprintf("/api/v1/storages?workspace_id=%s", workspace.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + &storages, ) assert.Contains(t, storages, savedStorage) - RemoveTestStorage(savedStorage.ID) + deleteStorage(t, router, savedStorage.ID, workspace.ID, owner.Token) + workspaces_testing.RemoveTestWorkspace(workspace, router) } func Test_UpdateExistingStorage_UpdatedStorageReturnedViaGet(t *testing.T) { - user := users.GetTestUser() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) router := createRouter() - storage := createNewStorage(user.UserID) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + storage := createNewStorage(workspace.ID) - // Save initial storage var savedStorage Storage test_utils.MakePostRequestAndUnmarshal( - t, router, "/api/v1/storages", user.Token, storage, http.StatusOK, &savedStorage, + t, + router, + "/api/v1/storages", + "Bearer "+owner.Token, + *storage, + http.StatusOK, + &savedStorage, ) - // Modify storage name updatedName := "Updated Storage " + uuid.New().String() savedStorage.Name = updatedName - // Update storage var updatedStorage Storage test_utils.MakePostRequestAndUnmarshal( - t, router, "/api/v1/storages", user.Token, savedStorage, http.StatusOK, &updatedStorage, + t, + router, + "/api/v1/storages", + "Bearer "+owner.Token, + savedStorage, + http.StatusOK, + &updatedStorage, ) - // Verify updated data assert.Equal(t, updatedName, updatedStorage.Name) assert.Equal(t, savedStorage.ID, updatedStorage.ID) - // Verify through GET - var retrievedStorage Storage - test_utils.MakeGetRequestAndUnmarshal( - t, - router, - "/api/v1/storages/"+updatedStorage.ID.String(), - user.Token, - http.StatusOK, - &retrievedStorage, - ) - - verifyStorageData(t, &updatedStorage, &retrievedStorage) - - // Verify storage is returned via GET all storages - var storages []Storage - test_utils.MakeGetRequestAndUnmarshal( - t, router, "/api/v1/storages", user.Token, http.StatusOK, &storages, - ) - - assert.Contains(t, storages, updatedStorage) - - RemoveTestStorage(updatedStorage.ID) + deleteStorage(t, router, updatedStorage.ID, workspace.ID, owner.Token) + workspaces_testing.RemoveTestWorkspace(workspace, router) } func Test_DeleteStorage_StorageNotReturnedViaGet(t *testing.T) { - user := users.GetTestUser() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) router := createRouter() - storage := createNewStorage(user.UserID) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + storage := createNewStorage(workspace.ID) - // Save initial storage var savedStorage Storage test_utils.MakePostRequestAndUnmarshal( - t, router, "/api/v1/storages", user.Token, storage, http.StatusOK, &savedStorage, + t, + router, + "/api/v1/storages", + "Bearer "+owner.Token, + *storage, + http.StatusOK, + &savedStorage, ) - // Delete storage test_utils.MakeDeleteRequest( - t, router, "/api/v1/storages/"+savedStorage.ID.String(), user.Token, http.StatusOK, + t, + router, + fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, ) - // Try to get deleted storage, should return error response := test_utils.MakeGetRequest( - t, router, "/api/v1/storages/"+savedStorage.ID.String(), user.Token, http.StatusBadRequest, + t, + router, + fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()), + "Bearer "+owner.Token, + http.StatusBadRequest, ) assert.Contains(t, string(response.Body), "error") - - // Verify storage is not returned via GET all storages - var storages []Storage - test_utils.MakeGetRequestAndUnmarshal( - t, router, "/api/v1/storages", user.Token, http.StatusOK, &storages, - ) - - assert.NotContains(t, storages, savedStorage) + workspaces_testing.RemoveTestWorkspace(workspace, router) } func Test_TestDirectStorageConnection_ConnectionEstablished(t *testing.T) { - user := users.GetTestUser() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) router := createRouter() - storage := createNewStorage(user.UserID) - + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + storage := createNewStorage(workspace.ID) response := test_utils.MakePostRequest( - t, router, "/api/v1/storages/direct-test", user.Token, storage, http.StatusOK, + t, router, "/api/v1/storages/direct-test", "Bearer "+owner.Token, *storage, http.StatusOK, ) assert.Contains(t, string(response.Body), "successful") + + workspaces_testing.RemoveTestWorkspace(workspace, router) } func Test_TestExistingStorageConnection_ConnectionEstablished(t *testing.T) { - user := users.GetTestUser() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) router := createRouter() - storage := createNewStorage(user.UserID) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + storage := createNewStorage(workspace.ID) var savedStorage Storage test_utils.MakePostRequestAndUnmarshal( - t, router, "/api/v1/storages", user.Token, storage, http.StatusOK, &savedStorage, + t, + router, + "/api/v1/storages", + "Bearer "+owner.Token, + *storage, + http.StatusOK, + &savedStorage, ) - // Test connection to existing storage response := test_utils.MakePostRequest( t, router, - "/api/v1/storages/"+savedStorage.ID.String()+"/test", - user.Token, + fmt.Sprintf("/api/v1/storages/%s/test", savedStorage.ID.String()), + "Bearer "+owner.Token, nil, http.StatusOK, ) assert.Contains(t, string(response.Body), "successful") - RemoveTestStorage(savedStorage.ID) + deleteStorage(t, router, savedStorage.ID, workspace.ID, owner.Token) + workspaces_testing.RemoveTestWorkspace(workspace, router) } -func Test_CallAllMethodsWithoutAuth_UnauthorizedErrorReturned(t *testing.T) { +func Test_ViewerCanViewStorages_ButCannotModify(t *testing.T) { + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + viewer := users_testing.CreateTestUser(users_enums.UserRoleMember) router := createRouter() - storage := createNewStorage(uuid.New()) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + viewer, + users_enums.WorkspaceRoleViewer, + owner.Token, + router, + ) + storage := createNewStorage(workspace.ID) - // Test endpoints without auth - endpoints := []struct { - method string - url string - body interface{} - }{ - {"GET", "/api/v1/storages", nil}, - {"GET", "/api/v1/storages/" + uuid.New().String(), nil}, - {"POST", "/api/v1/storages", storage}, - {"DELETE", "/api/v1/storages/" + uuid.New().String(), nil}, - {"POST", "/api/v1/storages/" + uuid.New().String() + "/test", nil}, - {"POST", "/api/v1/storages/direct-test", storage}, - } + var savedStorage Storage + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/storages", + "Bearer "+owner.Token, + *storage, + http.StatusOK, + &savedStorage, + ) - for _, endpoint := range endpoints { - testUnauthorizedEndpoint(t, router, endpoint.method, endpoint.url, endpoint.body) - } + // 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) + + // Viewer cannot CREATE storage + newStorage := createNewStorage(workspace.ID) + test_utils.MakePostRequest( + t, router, "/api/v1/storages", "Bearer "+viewer.Token, *newStorage, http.StatusForbidden, + ) + + // Viewer cannot UPDATE storage + savedStorage.Name = "Updated by viewer" + test_utils.MakePostRequest( + t, router, "/api/v1/storages", "Bearer "+viewer.Token, savedStorage, http.StatusForbidden, + ) + + // Viewer cannot DELETE storage + test_utils.MakeDeleteRequest( + t, + router, + fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()), + "Bearer "+viewer.Token, + http.StatusForbidden, + ) + + deleteStorage(t, router, savedStorage.ID, workspace.ID, owner.Token) + workspaces_testing.RemoveTestWorkspace(workspace, router) } -func testUnauthorizedEndpoint( - t *testing.T, - router *gin.Engine, - method, url string, - body interface{}, -) { - test_utils.MakeRequest(t, router, test_utils.RequestOptions{ - Method: method, - URL: url, - Body: body, - ExpectedStatus: http.StatusUnauthorized, - }) +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) + + // 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) + + // 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) +} + +func Test_UserNotInWorkspace_CannotAccessStorages(t *testing.T) { + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + outsider := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + storage := createNewStorage(workspace.ID) + + var savedStorage Storage + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/storages", + "Bearer "+owner.Token, + *storage, + http.StatusOK, + &savedStorage, + ) + + // Outsider cannot GET storages + test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/storages?workspace_id=%s", workspace.ID.String()), + "Bearer "+outsider.Token, + http.StatusForbidden, + ) + + // Outsider cannot CREATE storage + test_utils.MakePostRequest( + t, router, "/api/v1/storages", "Bearer "+outsider.Token, *storage, http.StatusForbidden, + ) + + // Outsider cannot UPDATE storage + test_utils.MakePostRequest( + t, + router, + "/api/v1/storages", + "Bearer "+outsider.Token, + savedStorage, + http.StatusForbidden, + ) + + // Outsider cannot DELETE storage + test_utils.MakeDeleteRequest( + t, + router, + fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()), + "Bearer "+outsider.Token, + http.StatusForbidden, + ) + + deleteStorage(t, router, savedStorage.ID, workspace.ID, owner.Token) + workspaces_testing.RemoveTestWorkspace(workspace, router) +} + +func Test_CrossWorkspaceSecurity_CannotAccessStorageFromAnotherWorkspace(t *testing.T) { + owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember) + owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace1 := workspaces_testing.CreateTestWorkspace("Workspace 1", owner1, router) + workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router) + storage1 := createNewStorage(workspace1.ID) + + var savedStorage Storage + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/storages", + "Bearer "+owner1.Token, + *storage1, + http.StatusOK, + &savedStorage, + ) + + // Try to access workspace1's storage with owner2 from workspace2 + response := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/storages/%s", savedStorage.ID.String()), + "Bearer "+owner2.Token, + http.StatusForbidden, + ) + assert.Contains(t, string(response.Body), "insufficient permissions") + + deleteStorage(t, router, savedStorage.ID, workspace1.ID, owner1.Token) + workspaces_testing.RemoveTestWorkspace(workspace1, router) + workspaces_testing.RemoveTestWorkspace(workspace2, router) } func createRouter() *gin.Engine { gin.SetMode(gin.TestMode) router := gin.New() - controller := GetStorageController() + v1 := router.Group("/api/v1") - controller.RegisterRoutes(v1) + protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService())) + + if routerGroup, ok := protected.(*gin.RouterGroup); ok { + GetStorageController().RegisterRoutes(routerGroup) + workspaces_controllers.GetWorkspaceController().RegisterRoutes(routerGroup) + workspaces_controllers.GetMembershipController().RegisterRoutes(routerGroup) + } + + audit_logs.SetupDependencies() + return router } -func createNewStorage(userID uuid.UUID) *Storage { +func createNewStorage(workspaceID uuid.UUID) *Storage { return &Storage{ - UserID: userID, + WorkspaceID: workspaceID, Type: StorageTypeLocal, Name: "Test Storage " + uuid.New().String(), LocalStorage: &local_storage.LocalStorage{}, @@ -225,5 +467,20 @@ func createNewStorage(userID uuid.UUID) *Storage { func verifyStorageData(t *testing.T, expected *Storage, actual *Storage) { assert.Equal(t, expected.Name, actual.Name) assert.Equal(t, expected.Type, actual.Type) - assert.Equal(t, expected.UserID, actual.UserID) + assert.Equal(t, expected.WorkspaceID, actual.WorkspaceID) +} + +func deleteStorage( + t *testing.T, + router *gin.Engine, + storageID, workspaceID uuid.UUID, + token string, +) { + test_utils.MakeDeleteRequest( + t, + router, + fmt.Sprintf("/api/v1/storages/%s", storageID.String()), + "Bearer "+token, + http.StatusOK, + ) } diff --git a/backend/internal/features/storages/di.go b/backend/internal/features/storages/di.go index dae582d..9bd1c40 100644 --- a/backend/internal/features/storages/di.go +++ b/backend/internal/features/storages/di.go @@ -1,16 +1,19 @@ package storages import ( - "postgresus-backend/internal/features/users" + audit_logs "postgresus-backend/internal/features/audit_logs" + workspaces_services "postgresus-backend/internal/features/workspaces/services" ) var storageRepository = &StorageRepository{} var storageService = &StorageService{ storageRepository, + workspaces_services.GetWorkspaceService(), + audit_logs.GetAuditLogService(), } var storageController = &StorageController{ storageService, - users.GetUserService(), + workspaces_services.GetWorkspaceService(), } func GetStorageService() *StorageService { @@ -20,3 +23,7 @@ func GetStorageService() *StorageService { func GetStorageController() *StorageController { return storageController } + +func SetupDependencies() { + workspaces_services.GetWorkspaceService().AddWorkspaceDeletionListener(storageService) +} diff --git a/backend/internal/features/storages/model.go b/backend/internal/features/storages/model.go index 3ac2528..a7c5105 100644 --- a/backend/internal/features/storages/model.go +++ b/backend/internal/features/storages/model.go @@ -14,7 +14,7 @@ import ( type Storage struct { ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"` - UserID uuid.UUID `json:"userId" gorm:"column:user_id;not null;type:uuid;index"` + WorkspaceID uuid.UUID `json:"workspaceId" gorm:"column:workspace_id;not null;type:uuid;index"` Type StorageType `json:"type" gorm:"column:type;not null;type:text"` Name string `json:"name" gorm:"column:name;not null;type:text"` LastSaveError *string `json:"lastSaveError" gorm:"column:last_save_error;type:text"` diff --git a/backend/internal/features/storages/repository.go b/backend/internal/features/storages/repository.go index 06b736a..afc1d18 100644 --- a/backend/internal/features/storages/repository.go +++ b/backend/internal/features/storages/repository.go @@ -104,7 +104,7 @@ func (r *StorageRepository) FindByID(id uuid.UUID) (*Storage, error) { return &s, nil } -func (r *StorageRepository) FindByUserID(userID uuid.UUID) ([]*Storage, error) { +func (r *StorageRepository) FindByWorkspaceID(workspaceID uuid.UUID) ([]*Storage, error) { var storages []*Storage if err := db. @@ -113,7 +113,7 @@ func (r *StorageRepository) FindByUserID(userID uuid.UUID) ([]*Storage, error) { Preload("S3Storage"). Preload("GoogleDriveStorage"). Preload("NASStorage"). - Where("user_id = ?", userID). + Where("workspace_id = ?", workspaceID). Order("name ASC"). Find(&storages).Error; err != nil { return nil, err diff --git a/backend/internal/features/storages/service.go b/backend/internal/features/storages/service.go index ab43f4a..396db4b 100644 --- a/backend/internal/features/storages/service.go +++ b/backend/internal/features/storages/service.go @@ -2,39 +2,70 @@ package storages import ( "errors" + "fmt" + + audit_logs "postgresus-backend/internal/features/audit_logs" users_models "postgresus-backend/internal/features/users/models" + workspaces_services "postgresus-backend/internal/features/workspaces/services" "github.com/google/uuid" ) type StorageService struct { storageRepository *StorageRepository + workspaceService *workspaces_services.WorkspaceService + auditLogService *audit_logs.AuditLogService } func (s *StorageService) SaveStorage( user *users_models.User, + workspaceID uuid.UUID, storage *Storage, ) error { - if storage.ID != uuid.Nil { + canManage, err := s.workspaceService.CanUserManageDBs(workspaceID, user) + if err != nil { + return err + } + if !canManage { + return errors.New("insufficient permissions to manage storage in this workspace") + } + + isUpdate := storage.ID != uuid.Nil + + if isUpdate { existingStorage, err := s.storageRepository.FindByID(storage.ID) if err != nil { return err } - if existingStorage.UserID != user.ID { - return errors.New("you have not access to this storage") + if existingStorage.WorkspaceID != workspaceID { + return errors.New("storage does not belong to this workspace") } - storage.UserID = existingStorage.UserID + storage.WorkspaceID = existingStorage.WorkspaceID } else { - storage.UserID = user.ID + storage.WorkspaceID = workspaceID } - _, err := s.storageRepository.Save(storage) + _, err = s.storageRepository.Save(storage) if err != nil { return err } + if isUpdate { + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Storage updated: %s", storage.Name), + &user.ID, + &workspaceID, + ) + } else { + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Storage created: %s", storage.Name), + &user.ID, + &workspaceID, + ) + } + return nil } @@ -47,11 +78,26 @@ func (s *StorageService) DeleteStorage( return err } - if storage.UserID != user.ID { - return errors.New("you have not access to this storage") + canManage, err := s.workspaceService.CanUserManageDBs(storage.WorkspaceID, user) + if err != nil { + return err + } + if !canManage { + return errors.New("insufficient permissions to manage storage in this workspace") } - return s.storageRepository.Delete(storage) + err = s.storageRepository.Delete(storage) + if err != nil { + return err + } + + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Storage deleted: %s", storage.Name), + &user.ID, + &storage.WorkspaceID, + ) + + return nil } func (s *StorageService) GetStorage( @@ -63,8 +109,12 @@ func (s *StorageService) GetStorage( return nil, err } - if storage.UserID != user.ID { - return nil, errors.New("you have not access to this storage") + canView, _, err := s.workspaceService.CanUserAccessWorkspace(storage.WorkspaceID, user) + if err != nil { + return nil, err + } + if !canView { + return nil, errors.New("insufficient permissions to view storage in this workspace") } return storage, nil @@ -72,8 +122,17 @@ func (s *StorageService) GetStorage( func (s *StorageService) GetStorages( user *users_models.User, + workspaceID uuid.UUID, ) ([]*Storage, error) { - return s.storageRepository.FindByUserID(user.ID) + canView, _, err := s.workspaceService.CanUserAccessWorkspace(workspaceID, user) + if err != nil { + return nil, err + } + if !canView { + return nil, errors.New("insufficient permissions to view storages in this workspace") + } + + return s.storageRepository.FindByWorkspaceID(workspaceID) } func (s *StorageService) TestStorageConnection( @@ -85,8 +144,12 @@ func (s *StorageService) TestStorageConnection( return err } - if storage.UserID != user.ID { - return errors.New("you have not access to this storage") + canView, _, err := s.workspaceService.CanUserAccessWorkspace(storage.WorkspaceID, user) + if err != nil { + return err + } + if !canView { + return errors.New("insufficient permissions to test storage in this workspace") } err = storage.TestConnection() @@ -116,3 +179,18 @@ func (s *StorageService) GetStorageByID( ) (*Storage, error) { return s.storageRepository.FindByID(id) } + +func (s *StorageService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error { + storages, err := s.storageRepository.FindByWorkspaceID(workspaceID) + if err != nil { + return fmt.Errorf("failed to get storages for workspace deletion: %w", err) + } + + for _, storage := range storages { + if err := s.storageRepository.Delete(storage); err != nil { + return fmt.Errorf("failed to delete storage %s: %w", storage.ID, err) + } + } + + return nil +} diff --git a/backend/internal/features/storages/testing.go b/backend/internal/features/storages/testing.go index c40872d..5372456 100644 --- a/backend/internal/features/storages/testing.go +++ b/backend/internal/features/storages/testing.go @@ -6,9 +6,9 @@ import ( "github.com/google/uuid" ) -func CreateTestStorage(userID uuid.UUID) *Storage { +func CreateTestStorage(workspaceID uuid.UUID) *Storage { storage := &Storage{ - UserID: userID, + WorkspaceID: workspaceID, Type: StorageTypeLocal, Name: "Test Storage " + uuid.New().String(), LocalStorage: &local_storage.LocalStorage{}, diff --git a/backend/internal/features/tests/postgresql_backup_restore_test.go b/backend/internal/features/tests/postgresql_backup_restore_test.go index a80ce78..06bc2a3 100644 --- a/backend/internal/features/tests/postgresql_backup_restore_test.go +++ b/backend/internal/features/tests/postgresql_backup_restore_test.go @@ -129,7 +129,7 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) { } storage := &storages.Storage{ - UserID: uuid.New(), + WorkspaceID: uuid.New(), Type: storages.StorageTypeLocal, Name: "Test Storage", LocalStorage: &local_storage.LocalStorage{}, diff --git a/backend/internal/features/users/controller.go b/backend/internal/features/users/controller.go deleted file mode 100644 index ea747a8..0000000 --- a/backend/internal/features/users/controller.go +++ /dev/null @@ -1,98 +0,0 @@ -package users - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "golang.org/x/time/rate" -) - -type UserController struct { - userService *UserService - signinLimiter *rate.Limiter -} - -func (c *UserController) RegisterRoutes(router *gin.RouterGroup) { - router.POST("/users/signup", c.SignUp) - router.POST("/users/signin", c.SignIn) - router.GET("/users/is-any-user-exist", c.IsAnyUserExist) -} - -// SignUp -// @Summary Register a new user -// @Description Register a new user with email and password -// @Tags users -// @Accept json -// @Produce json -// @Param request body SignUpRequest true "User signup data" -// @Success 200 -// @Failure 400 -// @Router /users/signup [post] -func (c *UserController) SignUp(ctx *gin.Context) { - var request SignUpRequest - if err := ctx.ShouldBindJSON(&request); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) - return - } - - err := c.userService.SignUp(&request) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - ctx.JSON(http.StatusOK, gin.H{"message": "User created successfully"}) -} - -// SignIn -// @Summary Authenticate a user -// @Description Authenticate a user with email and password -// @Tags users -// @Accept json -// @Produce json -// @Param request body SignInRequest true "User signin data" -// @Success 200 {object} SignInResponse -// @Failure 400 -// @Failure 429 {object} map[string]string "Rate limit exceeded" -// @Router /users/signin [post] -func (c *UserController) SignIn(ctx *gin.Context) { - // We use rate limiter to prevent brute force attacks - if !c.signinLimiter.Allow() { - ctx.JSON( - http.StatusTooManyRequests, - gin.H{"error": "Rate limit exceeded. Please try again later."}, - ) - return - } - - var request SignInRequest - if err := ctx.ShouldBindJSON(&request); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) - return - } - - response, err := c.userService.SignIn(&request) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - ctx.JSON(http.StatusOK, response) -} - -// IsAnyUserExist -// @Summary Check if any user exists -// @Description Check if any user exists in the system -// @Tags users -// @Produce json -// @Success 200 {object} map[string]bool -// @Router /users/is-any-user-exist [get] -func (c *UserController) IsAnyUserExist(ctx *gin.Context) { - isExist, err := c.userService.IsAnyUserExist() - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - ctx.JSON(http.StatusOK, gin.H{"isExist": isExist}) -} diff --git a/backend/internal/features/users/controllers/di.go b/backend/internal/features/users/controllers/di.go new file mode 100644 index 0000000..f796359 --- /dev/null +++ b/backend/internal/features/users/controllers/di.go @@ -0,0 +1,32 @@ +package users_controllers + +import ( + users_services "postgresus-backend/internal/features/users/services" + + "golang.org/x/time/rate" +) + +var userController = &UserController{ + users_services.GetUserService(), + rate.NewLimiter(rate.Limit(3), 3), // 3 rps with 3 burst +} + +var settingsController = &SettingsController{ + users_services.GetSettingsService(), +} + +var managementController = &ManagementController{ + users_services.GetManagementService(), +} + +func GetUserController() *UserController { + return userController +} + +func GetSettingsController() *SettingsController { + return settingsController +} + +func GetManagementController() *ManagementController { + return managementController +} diff --git a/backend/internal/features/users/controllers/e2e_test.go b/backend/internal/features/users/controllers/e2e_test.go new file mode 100644 index 0000000..91fdda9 --- /dev/null +++ b/backend/internal/features/users/controllers/e2e_test.go @@ -0,0 +1,263 @@ +package users_controllers + +import ( + "net/http" + "testing" + + users_dto "postgresus-backend/internal/features/users/dto" + users_enums "postgresus-backend/internal/features/users/enums" + users_middleware "postgresus-backend/internal/features/users/middleware" + users_services "postgresus-backend/internal/features/users/services" + users_testing "postgresus-backend/internal/features/users/testing" + test_utils "postgresus-backend/internal/util/testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "golang.org/x/time/rate" +) + +func Test_AdminLifecycleE2E_CompletesSuccessfully(t *testing.T) { + router := createE2ETestRouter() + + users_testing.RecreateInitialAdmin() + + // 1. Set initial admin password + adminPasswordRequest := users_dto.SetAdminPasswordRequestDTO{ + Password: "adminpassword123", + } + + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/admin/set-password", + "", + adminPasswordRequest, + http.StatusOK, + ) + + // 2. Admin signs in + adminSigninRequest := users_dto.SignInRequestDTO{ + Email: "admin", + Password: "adminpassword123", + } + + var adminSigninResponse users_dto.SignInResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/users/signin", + "", + adminSigninRequest, + http.StatusOK, + &adminSigninResponse, + ) + + // 3. Admin invites a user + workspaceID := uuid.New() + workspaceRole := users_enums.WorkspaceRoleMember + invitedUserEmail := "invited" + uuid.New().String() + "@example.com" + inviteRequest := users_dto.InviteUserRequestDTO{ + Email: invitedUserEmail, + IntendedWorkspaceID: &workspaceID, + IntendedWorkspaceRole: &workspaceRole, + } + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/invite", + "Bearer "+adminSigninResponse.Token, + inviteRequest, + http.StatusOK, + ) + + // 4. Invited user signs up + userSignupRequest := users_dto.SignUpRequestDTO{ + Email: invitedUserEmail, + Password: "userpassword123", + Name: "Invited User", + } + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/signup", + "", + userSignupRequest, + http.StatusOK, + ) + + // 5. User signs in + userSigninRequest := users_dto.SignInRequestDTO{ + Email: invitedUserEmail, + Password: "userpassword123", + } + + var userSigninResponse users_dto.SignInResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/users/signin", + "", + userSigninRequest, + http.StatusOK, + &userSigninResponse, + ) + + // 6. Admin lists users and sees new user + var listUsersResponse users_dto.ListUsersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users", + "Bearer "+adminSigninResponse.Token, + http.StatusOK, + &listUsersResponse, + ) + assert.GreaterOrEqual(t, len(listUsersResponse.Users), 2) // Admin + new user +} + +func Test_UserLifecycleE2E_CompletesSuccessfully(t *testing.T) { + router := createE2ETestRouter() + users_testing.ResetSettingsToDefaults() + + // 1. User registers + userEmail := "testuser" + uuid.New().String() + "@example.com" + userSignupRequest := users_dto.SignUpRequestDTO{ + Email: userEmail, + Password: "userpassword123", + Name: "Test User", + } + + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/signup", + "", + userSignupRequest, + http.StatusOK, + ) + + // 2. User signs in + userSigninRequest := users_dto.SignInRequestDTO{ + Email: userEmail, + Password: "userpassword123", + } + + var signinResponse users_dto.SignInResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/users/signin", + "", + userSigninRequest, + http.StatusOK, + &signinResponse, + ) + assert.NotEmpty(t, signinResponse.Token) + assert.NotEqual(t, uuid.Nil, signinResponse.UserID) + + // 3. User gets own profile + var profileResponse users_dto.UserProfileResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users/"+signinResponse.UserID.String(), + "Bearer "+signinResponse.Token, + http.StatusOK, + &profileResponse, + ) + assert.Equal(t, signinResponse.UserID, profileResponse.ID) + assert.Equal(t, userEmail, profileResponse.Email) + assert.Equal(t, users_enums.UserRoleMember, profileResponse.Role) + assert.True(t, profileResponse.IsActive) +} + +// Test router creation helpers +func createUserTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + + v1 := router.Group("/api/v1") + + // Register public routes + GetUserController().RegisterRoutes(v1) + + // Register protected routes with auth middleware + protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService())) + GetUserController().RegisterProtectedRoutes(protected.(*gin.RouterGroup)) + GetUserController().SetSignInLimiter(rate.NewLimiter(rate.Limit(100), 100)) + + // Setup audit log service + users_services.GetUserService().SetAuditLogWriter(&AuditLogWriterStub{}) + + return router +} + +func createSettingsTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + + v1 := router.Group("/api/v1") + + // Register protected routes with auth middleware + protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService())) + GetSettingsController().RegisterRoutes(protected.(*gin.RouterGroup)) + + // Setup audit log service + users_services.GetUserService().SetAuditLogWriter(&AuditLogWriterStub{}) + users_services.GetSettingsService().SetAuditLogWriter(&AuditLogWriterStub{}) + users_services.GetManagementService().SetAuditLogWriter(&AuditLogWriterStub{}) + + return router +} + +func createManagementTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + + v1 := router.Group("/api/v1") + + // Register protected routes with auth middleware + protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService())) + GetManagementController().RegisterRoutes(protected.(*gin.RouterGroup)) + + // Setup audit log service + users_services.GetUserService().SetAuditLogWriter(&AuditLogWriterStub{}) + users_services.GetSettingsService().SetAuditLogWriter(&AuditLogWriterStub{}) + users_services.GetManagementService().SetAuditLogWriter(&AuditLogWriterStub{}) + + return router +} + +func createE2ETestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + + v1 := router.Group("/api/v1") + + // Register all routes + GetUserController().RegisterRoutes(v1) + + // Register protected routes with auth middleware + protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService())) + GetUserController().RegisterProtectedRoutes(protected.(*gin.RouterGroup)) + GetSettingsController().RegisterRoutes(protected.(*gin.RouterGroup)) + GetManagementController().RegisterRoutes(protected.(*gin.RouterGroup)) + + // Setup audit log service + users_services.GetUserService().SetAuditLogWriter(&AuditLogWriterStub{}) + users_services.GetSettingsService().SetAuditLogWriter(&AuditLogWriterStub{}) + users_services.GetManagementService().SetAuditLogWriter(&AuditLogWriterStub{}) + + return router +} + +type AuditLogWriterStub struct{} + +func (a *AuditLogWriterStub) WriteAuditLog( + message string, + userID *uuid.UUID, + workspaceID *uuid.UUID, +) { + // do nothing +} diff --git a/backend/internal/features/users/controllers/management_controller.go b/backend/internal/features/users/controllers/management_controller.go new file mode 100644 index 0000000..7728b38 --- /dev/null +++ b/backend/internal/features/users/controllers/management_controller.go @@ -0,0 +1,260 @@ +package users_controllers + +import ( + "fmt" + "net/http" + + user_dto "postgresus-backend/internal/features/users/dto" + user_enums "postgresus-backend/internal/features/users/enums" + user_middleware "postgresus-backend/internal/features/users/middleware" + users_services "postgresus-backend/internal/features/users/services" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type ManagementController struct { + managementService *users_services.UserManagementService +} + +func (c *ManagementController) RegisterRoutes(router *gin.RouterGroup) { + router.GET("/users", user_middleware.RequireRole(user_enums.UserRoleAdmin), c.GetUsers) + router.GET("/users/:id", c.GetUserProfile) + router.POST( + "/users/:id/deactivate", + user_middleware.RequireRole(user_enums.UserRoleAdmin), + c.DeactivateUser, + ) + router.POST( + "/users/:id/activate", + user_middleware.RequireRole(user_enums.UserRoleAdmin), + c.ActivateUser, + ) + router.PUT( + "/users/:id/role", + user_middleware.RequireRole(user_enums.UserRoleAdmin), + c.ChangeUserRole, + ) +} + +// ListUsers +// @Summary List users +// @Description Get list of users (admin only) +// @Tags user-management +// @Produce json +// @Security BearerAuth +// @Param limit query int false "Number of items per page" default(20) +// @Param offset query int false "Page offset" default(0) +// @Param beforeDate query string false "Filter users created before this date (RFC3339 format)" format(date-time) +// @Param query query string false "Search by email or name (case-insensitive)" +// @Success 200 {object} users_dto.ListUsersResponseDTO +// @Failure 401 {object} map[string]string "Unauthorized" +// @Failure 403 {object} map[string]string "Forbidden" +// @Router /users [get] +func (c *ManagementController) GetUsers(ctx *gin.Context) { + fmt.Println("GetUsers") + + user, ok := user_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + request := &user_dto.ListUsersRequestDTO{} + if err := ctx.ShouldBindQuery(request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) + return + } + + // Set defaults if not provided + if request.Limit <= 0 || request.Limit > 100 { + request.Limit = 20 + } + if request.Offset < 0 { + request.Offset = 0 + } + + users, total, err := c.managementService.GetUsers( + user, + request.Limit, + request.Offset, + request.BeforeDate, + request.Query, + ) + if err != nil { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + // Convert to response format + userProfiles := make([]user_dto.UserProfileResponseDTO, len(users)) + for i, u := range users { + userProfiles[i] = user_dto.UserProfileResponseDTO{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + Role: u.Role, + IsActive: u.IsActiveUser(), + CreatedAt: u.CreatedAt, + } + } + + response := user_dto.ListUsersResponseDTO{ + Users: userProfiles, + Total: total, + } + + ctx.JSON(http.StatusOK, response) +} + +// GetUserProfile +// @Summary Get user profile +// @Description Get user profile information (users can view own profile, admins can view any) +// @Tags user-management +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Success 200 {object} users_dto.UserProfileResponseDTO +// @Failure 400 {object} map[string]string "Bad request" +// @Failure 401 {object} map[string]string "Unauthorized" +// @Failure 403 {object} map[string]string "Forbidden" +// @Router /users/{id} [get] +func (c *ManagementController) GetUserProfile(ctx *gin.Context) { + currentUser, ok := user_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + userIDStr := ctx.Param("id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + user, err := c.managementService.GetUserProfile(userID, currentUser) + if err != nil { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + profile := user_dto.UserProfileResponseDTO{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Role: user.Role, + IsActive: user.IsActiveUser(), + CreatedAt: user.CreatedAt, + } + + ctx.JSON(http.StatusOK, profile) +} + +// DeactivateUser +// @Summary Deactivate user +// @Description Deactivate a user account (admin only) +// @Tags user-management +// @Security BearerAuth +// @Param id path string true "User ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string "Bad request" +// @Failure 401 {object} map[string]string "Unauthorized" +// @Failure 403 {object} map[string]string "Forbidden" +// @Router /users/{id}/deactivate [post] +func (c *ManagementController) DeactivateUser(ctx *gin.Context) { + currentUser, ok := user_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + userIDStr := ctx.Param("id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + if err := c.managementService.DeactivateUser(userID, currentUser); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "User deactivated successfully"}) +} + +// ActivateUser +// @Summary Activate user +// @Description Activate a user account (admin only) +// @Tags user-management +// @Security BearerAuth +// @Param id path string true "User ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string "Bad request" +// @Failure 401 {object} map[string]string "Unauthorized" +// @Failure 403 {object} map[string]string "Forbidden" +// @Router /users/{id}/activate [post] +func (c *ManagementController) ActivateUser(ctx *gin.Context) { + currentUser, ok := user_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + userIDStr := ctx.Param("id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + if err := c.managementService.ActivateUser(userID, currentUser); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "User activated successfully"}) +} + +// ChangeUserRole +// @Summary Change user role +// @Description Change a user's role (admin only) +// @Tags user-management +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Param request body users_dto.ChangeUserRoleRequestDTO true "Role change data" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string "Bad request" +// @Failure 401 {object} map[string]string "Unauthorized" +// @Failure 403 {object} map[string]string "Forbidden" +// @Router /users/{id}/role [put] +func (c *ManagementController) ChangeUserRole(ctx *gin.Context) { + currentUser, ok := user_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + userIDStr := ctx.Param("id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + var request user_dto.ChangeUserRoleRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + if err := c.managementService.ChangeUserRole(userID, request.Role, currentUser); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "User role changed successfully"}) +} diff --git a/backend/internal/features/users/controllers/management_controller_test.go b/backend/internal/features/users/controllers/management_controller_test.go new file mode 100644 index 0000000..9c82491 --- /dev/null +++ b/backend/internal/features/users/controllers/management_controller_test.go @@ -0,0 +1,808 @@ +package users_controllers + +import ( + "net/http" + "testing" + "time" + + "postgresus-backend/internal/features/audit_logs" + users_dto "postgresus-backend/internal/features/users/dto" + users_enums "postgresus-backend/internal/features/users/enums" + users_middleware "postgresus-backend/internal/features/users/middleware" + users_services "postgresus-backend/internal/features/users/services" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers" + workspaces_dto "postgresus-backend/internal/features/workspaces/dto" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func Test_GetUsersList_WhenUserIsAdmin_ReturnsUsers(t *testing.T) { + router := createManagementTestRouter() + + // Create admin user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + var response users_dto.ListUsersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users", + "Bearer "+testUser.Token, + http.StatusOK, + &response, + ) + + assert.NotNil(t, response.Users) + assert.GreaterOrEqual(t, response.Total, int64(1)) // At least the test user should exist +} + +func Test_GetUsersList_WhenUserIsMember_ReturnsForbidden(t *testing.T) { + router := createManagementTestRouter() + + // Create member user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + resp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/users", + "Bearer "+testUser.Token, + http.StatusForbidden, + ) + assert.Contains(t, string(resp.Body), "permissions") +} + +func Test_GetUsersList_WithPagination_RespectsLimits(t *testing.T) { + router := createManagementTestRouter() + + // Create admin user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + var response users_dto.ListUsersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users?limit=5&offset=0", + "Bearer "+testUser.Token, + http.StatusOK, + &response, + ) + + assert.NotNil(t, response.Users) + assert.LessOrEqual(t, len(response.Users), 5) // Should respect limit +} + +func Test_GetUsersList_WithBeforeDateFilter_ReturnsFilteredUsers(t *testing.T) { + router := createManagementTestRouter() + + // Create admin user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + // Test with beforeDate filter + beforeDate := "2024-01-01T00:00:00Z" + var response users_dto.ListUsersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users?beforeDate="+beforeDate, + "Bearer "+testUser.Token, + http.StatusOK, + &response, + ) + + assert.NotNil(t, response.Users) + // All returned users should have been created before the specified date + for _, user := range response.Users { + assert.True(t, user.CreatedAt.Before(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))) + } +} + +func Test_GetUsersList_WithInvalidDateFilter_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create admin user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + // Test with invalid date format + resp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/users?beforeDate=invalid-date", + "Bearer "+testUser.Token, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "Invalid query parameters") +} + +func Test_GetUsersList_WithSearchQuery_ReturnsFilteredUsers(t *testing.T) { + router := createManagementTestRouter() + + // Create admin user and get token + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + // Create test users with specific emails and names + user1 := users_testing.CreateTestUser(users_enums.UserRoleMember) + user2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + + // Test searching by email (partial match) + emailPart := user1.Email[:5] + var emailResponse users_dto.ListUsersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users?query="+emailPart, + "Bearer "+adminUser.Token, + http.StatusOK, + &emailResponse, + ) + + assert.NotNil(t, emailResponse.Users) + found := false + for _, u := range emailResponse.Users { + if u.ID == user1.UserID { + found = true + break + } + } + assert.True(t, found, "Expected user1 to be in search results") + + // Test case-insensitive search + upperEmailPart := user2.Email[:5] + var caseInsensitiveResponse users_dto.ListUsersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users?query="+upperEmailPart, + "Bearer "+adminUser.Token, + http.StatusOK, + &caseInsensitiveResponse, + ) + + assert.NotNil(t, caseInsensitiveResponse.Users) + found = false + for _, u := range caseInsensitiveResponse.Users { + if u.ID == user2.UserID { + found = true + break + } + } + assert.True(t, found, "Expected user2 to be in case-insensitive search results") + + // Test searching by name + var nameResponse users_dto.ListUsersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users?query=Test", + "Bearer "+adminUser.Token, + http.StatusOK, + &nameResponse, + ) + + assert.NotNil(t, nameResponse.Users) + assert.GreaterOrEqual(t, len(nameResponse.Users), 1, "Should find users with 'Test' in name") +} + +func Test_GetUsersList_WithoutAuth_ReturnsUnauthorized(t *testing.T) { + router := createManagementTestRouter() + + test_utils.MakeGetRequest(t, router, "/api/v1/users", "", http.StatusUnauthorized) +} + +func Test_GetUserProfile_WhenAccessingOwnProfile_ReturnsProfile(t *testing.T) { + router := createManagementTestRouter() + + // Create member user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + var response users_dto.UserProfileResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users/"+testUser.UserID.String(), + "Bearer "+testUser.Token, + http.StatusOK, + &response, + ) + + assert.Equal(t, testUser.UserID, response.ID) + assert.Equal(t, users_enums.UserRoleMember, response.Role) +} + +func Test_GetUserProfile_WhenUserIsAdmin_ReturnsProfile(t *testing.T) { + router := createManagementTestRouter() + + // Create both admin and regular user + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + regularUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + var response users_dto.UserProfileResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users/"+regularUser.UserID.String(), + "Bearer "+adminUser.Token, + http.StatusOK, + &response, + ) + + assert.Equal(t, regularUser.UserID, response.ID) +} + +func Test_GetUserProfile_WhenAccessingOtherUserAsMember_ReturnsForbidden(t *testing.T) { + router := createManagementTestRouter() + + // Create two member users + user1 := users_testing.CreateTestUser(users_enums.UserRoleMember) + user2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + + test_utils.MakeGetRequest( + t, + router, + "/api/v1/users/"+user2.UserID.String(), + "Bearer "+user1.Token, + http.StatusForbidden, + ) +} + +func Test_GetUserProfile_WithNonExistentUser_ReturnsForbidden(t *testing.T) { + router := createManagementTestRouter() + + // Create admin user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + // Try to access non-existent user + test_utils.MakeGetRequest( + t, + router, + "/api/v1/users/00000000-0000-0000-0000-000000000000", + "Bearer "+testUser.Token, + http.StatusForbidden, + ) +} + +func Test_GetUserProfile_WithInvalidUserID_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create admin user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + resp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/users/invalid-uuid", + "Bearer "+testUser.Token, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "Invalid user ID") +} + +func Test_DeactivateUser_WhenUserIsAdmin_UserDeactivated(t *testing.T) { + router := createManagementTestRouter() + + // Create admin and target user + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + targetUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+targetUser.UserID.String()+"/deactivate", + "Bearer "+adminUser.Token, + nil, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "User deactivated successfully") +} + +func Test_DeactivateUser_WhenUserIsMember_ReturnsForbidden(t *testing.T) { + router := createManagementTestRouter() + + // Create two member users + user1 := users_testing.CreateTestUser(users_enums.UserRoleMember) + user2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+user2.UserID.String()+"/deactivate", + "Bearer "+user1.Token, + nil, + http.StatusForbidden, + ) +} + +func Test_DeactivateUser_WhenDeactivatingOwnAccount_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create admin user + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+adminUser.UserID.String()+"/deactivate", + "Bearer "+adminUser.Token, + nil, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "cannot deactivate your own account") +} + +func Test_ActivateUser_WhenUserIsAdmin_UserActivated(t *testing.T) { + router := createManagementTestRouter() + + // Create admin and target user + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + targetUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + // First deactivate the user + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+targetUser.UserID.String()+"/deactivate", + "Bearer "+adminUser.Token, + nil, + http.StatusOK, + ) + + // Now activate the user + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+targetUser.UserID.String()+"/activate", + "Bearer "+adminUser.Token, + nil, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "User activated successfully") +} + +func Test_ActivateUser_WhenUserIsMember_ReturnsForbidden(t *testing.T) { + router := createManagementTestRouter() + + // Create two member users + user1 := users_testing.CreateTestUser(users_enums.UserRoleMember) + user2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+user2.UserID.String()+"/activate", + "Bearer "+user1.Token, + nil, + http.StatusForbidden, + ) +} + +func Test_ChangeUserRole_WhenUserIsRootAdmin_RoleChanged(t *testing.T) { + router := createManagementTestRouter() + + // Create root admin and target user + rootAdmin := users_testing.ReacreateInitAdminAndGetAccess() + targetUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + request := users_dto.ChangeUserRoleRequestDTO{ + Role: users_enums.UserRoleAdmin, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/users/"+targetUser.UserID.String()+"/role", + "Bearer "+rootAdmin.Token, + request, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "User role changed successfully") +} + +func Test_ChangeUserRole_WhenUserIsMember_ReturnsForbidden(t *testing.T) { + router := createManagementTestRouter() + + // Create two member users + user1 := users_testing.CreateTestUser(users_enums.UserRoleMember) + user2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + + request := users_dto.ChangeUserRoleRequestDTO{ + Role: users_enums.UserRoleAdmin, + } + + test_utils.MakePutRequest( + t, + router, + "/api/v1/users/"+user2.UserID.String()+"/role", + "Bearer "+user1.Token, + request, + http.StatusForbidden, + ) +} + +func Test_ChangeUserRole_WhenChangingOwnRole_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create admin user + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + request := users_dto.ChangeUserRoleRequestDTO{ + Role: users_enums.UserRoleMember, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/users/"+adminUser.UserID.String()+"/role", + "Bearer "+adminUser.Token, + request, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "cannot change your own role") +} + +func Test_ChangeUserRole_WithInvalidRole_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create admin and target user + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + targetUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + // Test with invalid JSON structure containing invalid role + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "PUT", + URL: "/api/v1/users/" + targetUser.UserID.String() + "/role", + Body: map[string]string{"role": "INVALID_ROLE"}, + AuthToken: "Bearer " + adminUser.Token, + ExpectedStatus: http.StatusBadRequest, + }) + + assert.NotEmpty(t, resp.Body) +} + +func Test_ChangeUserRole_WithInvalidJSON_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create admin and target user + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + targetUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + // Test with invalid JSON structure + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "PUT", + URL: "/api/v1/users/" + targetUser.UserID.String() + "/role", + Body: "invalid json", + AuthToken: "Bearer " + adminUser.Token, + ExpectedStatus: http.StatusBadRequest, + }) + + assert.Contains(t, string(resp.Body), "Invalid request format") +} + +// Tests for root admin restrictions +func Test_ChangeUserRole_WhenRegularAdminPromotesToAdmin_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create regular admin and target user + regularAdmin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + targetUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + request := users_dto.ChangeUserRoleRequestDTO{ + Role: users_enums.UserRoleAdmin, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/users/"+targetUser.UserID.String()+"/role", + "Bearer "+regularAdmin.Token, + request, + http.StatusBadRequest, + ) + assert.Contains( + t, + string(resp.Body), + "only the root admin user can promote users to admin or demote admin users", + ) +} + +func Test_ChangeUserRole_WhenRegularAdminDemotesAdmin_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create regular admin and admin target user + regularAdmin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + adminTargetUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + request := users_dto.ChangeUserRoleRequestDTO{ + Role: users_enums.UserRoleMember, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/users/"+adminTargetUser.UserID.String()+"/role", + "Bearer "+regularAdmin.Token, + request, + http.StatusBadRequest, + ) + assert.Contains( + t, + string(resp.Body), + "only the root admin user can promote users to admin or demote admin users", + ) +} + +func Test_DeactivateUser_WhenRegularAdminDeactivatesAdmin_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create regular admin and admin target user + regularAdmin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + adminTargetUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+adminTargetUser.UserID.String()+"/deactivate", + "Bearer "+regularAdmin.Token, + nil, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "only the root admin user can deactivate admin accounts") +} + +func Test_ActivateUser_WhenRegularAdminActivatesAdmin_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create regular admin and admin target user + regularAdmin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + adminTargetUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+adminTargetUser.UserID.String()+"/activate", + "Bearer "+regularAdmin.Token, + nil, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "only the root admin user can activate admin accounts") +} + +func Test_ChangeUserRole_WhenRootAdminPromotesToAdmin_RoleChanged(t *testing.T) { + router := createManagementTestRouter() + + // Create root admin and target user + rootAdmin := users_testing.ReacreateInitAdminAndGetAccess() + targetUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + request := users_dto.ChangeUserRoleRequestDTO{ + Role: users_enums.UserRoleAdmin, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/users/"+targetUser.UserID.String()+"/role", + "Bearer "+rootAdmin.Token, + request, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "User role changed successfully") +} + +func Test_ChangeUserRole_WhenRootAdminDemotesAdmin_RoleChanged(t *testing.T) { + router := createManagementTestRouter() + + // Create root admin and admin target user + rootAdmin := users_testing.ReacreateInitAdminAndGetAccess() + adminTargetUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + request := users_dto.ChangeUserRoleRequestDTO{ + Role: users_enums.UserRoleMember, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/users/"+adminTargetUser.UserID.String()+"/role", + "Bearer "+rootAdmin.Token, + request, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "User role changed successfully") +} + +func Test_DeactivateUser_WhenRootAdminDeactivatesAdmin_UserDeactivated(t *testing.T) { + router := createManagementTestRouter() + + // Create root admin and admin target user + rootAdmin := users_testing.ReacreateInitAdminAndGetAccess() + adminTargetUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+adminTargetUser.UserID.String()+"/deactivate", + "Bearer "+rootAdmin.Token, + nil, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "User deactivated successfully") +} + +func Test_ActivateUser_WhenRootAdminActivatesAdmin_UserActivated(t *testing.T) { + router := createManagementTestRouter() + + // Create root admin and admin target user + rootAdmin := users_testing.ReacreateInitAdminAndGetAccess() + adminTargetUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + // First deactivate the admin user + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+adminTargetUser.UserID.String()+"/deactivate", + "Bearer "+rootAdmin.Token, + nil, + http.StatusOK, + ) + + // Now activate the admin user + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+adminTargetUser.UserID.String()+"/activate", + "Bearer "+rootAdmin.Token, + nil, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "User activated successfully") +} + +func Test_ChangeUserRole_WhenRootAdminChangesOwnRole_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create root admin + rootAdmin := users_testing.ReacreateInitAdminAndGetAccess() + + request := users_dto.ChangeUserRoleRequestDTO{ + Role: users_enums.UserRoleMember, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/users/"+rootAdmin.UserID.String()+"/role", + "Bearer "+rootAdmin.Token, + request, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "cannot change your own role") +} + +func Test_DeactivateUser_WhenRootAdminDeactivatesOwnAccount_ReturnsBadRequest(t *testing.T) { + router := createManagementTestRouter() + + // Create root admin + rootAdmin := users_testing.ReacreateInitAdminAndGetAccess() + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/"+rootAdmin.UserID.String()+"/deactivate", + "Bearer "+rootAdmin.Token, + nil, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "cannot deactivate your own account") +} + +func Test_InviteUserToWorkspace_MembershipReceivedAfterSignUp(t *testing.T) { + router := createInviteWorkspaceTestRouter() + users_testing.EnableMemberInvitations() + users_testing.EnableExternalRegistrations() + defer users_testing.ResetSettingsToDefaults() + + // 1. Create workspace owner and workspace + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI( + "Invite Test Workspace", + owner, + router, + ) + + // 2. Invite non-existing user to workspace + inviteEmail := "invited-" + uuid.New().String() + "@example.com" + inviteResponse := workspaces_testing.InviteMemberToWorkspace( + workspace, + inviteEmail, + users_enums.WorkspaceRoleMember, + owner.Token, + router, + ) + + assert.Equal(t, workspaces_dto.AddStatusInvited, inviteResponse.Status) + + // 3. Sign up the invited user + signUpRequest := users_dto.SignUpRequestDTO{ + Email: inviteEmail, + Password: "testpassword123", + Name: "Invited User", + } + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/signup", + "", + signUpRequest, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "User created successfully") + + // 4. Sign in the newly registered user + signInRequest := users_dto.SignInRequestDTO{ + Email: inviteEmail, + Password: "testpassword123", + } + + var signInResponse users_dto.SignInResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/users/signin", + "", + signInRequest, + http.StatusOK, + &signInResponse, + ) + + // 5. Verify user is automatically added as member to workspace + var membersResponse workspaces_dto.GetMembersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+signInResponse.Token, + http.StatusOK, + &membersResponse, + ) + + var foundMember *workspaces_dto.WorkspaceMemberResponseDTO + for _, member := range membersResponse.Members { + if member.UserID == signInResponse.UserID { + foundMember = &member + break + } + } + + assert.NotNil( + t, + foundMember, + "Invited user should be automatically added as workspace member after sign up", + ) + assert.Equal(t, users_enums.WorkspaceRoleMember, foundMember.Role) + assert.Equal(t, inviteEmail, foundMember.Email) +} + +func createInviteWorkspaceTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + + v1 := router.Group("/api/v1") + + GetUserController().RegisterRoutes(v1) + + protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService())) + + GetManagementController().RegisterRoutes(protected.(*gin.RouterGroup)) + GetUserController().RegisterProtectedRoutes(protected.(*gin.RouterGroup)) + workspaces_controllers.GetWorkspaceController().RegisterRoutes(protected.(*gin.RouterGroup)) + workspaces_controllers.GetMembershipController().RegisterRoutes(protected.(*gin.RouterGroup)) + audit_logs.SetupDependencies() + + return router +} diff --git a/backend/internal/features/users/controllers/settings_controller.go b/backend/internal/features/users/controllers/settings_controller.go new file mode 100644 index 0000000..adcd7ec --- /dev/null +++ b/backend/internal/features/users/controllers/settings_controller.go @@ -0,0 +1,81 @@ +package users_controllers + +import ( + "net/http" + + user_enums "postgresus-backend/internal/features/users/enums" + user_middleware "postgresus-backend/internal/features/users/middleware" + user_models "postgresus-backend/internal/features/users/models" + users_services "postgresus-backend/internal/features/users/services" + + "github.com/gin-gonic/gin" +) + +type SettingsController struct { + settingsService *users_services.SettingsService +} + +func (c *SettingsController) RegisterRoutes(router *gin.RouterGroup) { + router.GET("/users/settings", c.GetUsersSettings) + router.PUT( + "/users/settings", + user_middleware.RequireRole(user_enums.UserRoleAdmin), + c.UpdateUsersSettings, + ) +} + +// GetUsersSettings +// @Summary Get users settings +// @Description Get global users settings (admin only) +// @Tags settings +// @Produce json +// @Security BearerAuth +// @Success 200 {object} users_models.UsersSettings +// @Failure 401 {object} map[string]string "Unauthorized" +// @Failure 403 {object} map[string]string "Forbidden" +// @Failure 500 {object} map[string]string "Internal server error" +// @Router /users/settings [get] +func (c *SettingsController) GetUsersSettings(ctx *gin.Context) { + settings, err := c.settingsService.GetSettings() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"}) + return + } + + ctx.JSON(http.StatusOK, settings) +} + +// UpdateUsersSettings +// @Summary Update users settings +// @Description Update global users settings (admin only) +// @Tags settings +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body users_models.UsersSettings true "Settings update data" +// @Success 200 {object} users_models.UsersSettings +// @Failure 400 {object} map[string]string "Bad request" +// @Failure 401 {object} map[string]string "Unauthorized" +// @Failure 403 {object} map[string]string "Forbidden" +// @Router /users/settings [put] +func (c *SettingsController) UpdateUsersSettings(ctx *gin.Context) { + user, ok := user_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var request user_models.UsersSettings + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + settings, err := c.settingsService.UpdateSettings(request, user) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, settings) +} diff --git a/backend/internal/features/users/controllers/settings_controller_test.go b/backend/internal/features/users/controllers/settings_controller_test.go new file mode 100644 index 0000000..efa6d8c --- /dev/null +++ b/backend/internal/features/users/controllers/settings_controller_test.go @@ -0,0 +1,182 @@ +package users_controllers + +import ( + "net/http" + "testing" + + users_enums "postgresus-backend/internal/features/users/enums" + users_models "postgresus-backend/internal/features/users/models" + users_testing "postgresus-backend/internal/features/users/testing" + test_utils "postgresus-backend/internal/util/testing" + + "github.com/stretchr/testify/assert" +) + +func Test_GetUserSettings_WhenUserIsAdmin_ReturnsSettings(t *testing.T) { + users_testing.ResetSettingsToDefaults() + router := createSettingsTestRouter() + + // Create admin user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + var response users_models.UsersSettings + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users/settings", + "Bearer "+testUser.Token, + http.StatusOK, + &response, + ) + + // Default settings should be true for all + assert.True(t, response.IsAllowExternalRegistrations) + assert.True(t, response.IsAllowMemberInvitations) + assert.True(t, response.IsMemberAllowedToCreateWorkspaces) +} + +func Test_GetUserSettings_WhenUserIsMember_ReturnsSettings(t *testing.T) { + users_testing.ResetSettingsToDefaults() + router := createSettingsTestRouter() + + // Create member user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + _ = test_utils.MakeGetRequest( + t, + router, + "/api/v1/users/settings", + "Bearer "+testUser.Token, + http.StatusOK, + ) +} + +func Test_GetUserSettings_WithoutAuth_ReturnsUnauthorized(t *testing.T) { + users_testing.ResetSettingsToDefaults() + router := createSettingsTestRouter() + + test_utils.MakeGetRequest(t, router, "/api/v1/users/settings", "", http.StatusUnauthorized) +} + +func Test_UpdateUserSettings_WhenUserIsAdmin_SettingsUpdated(t *testing.T) { + users_testing.ResetSettingsToDefaults() + router := createSettingsTestRouter() + + // Create admin user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + // Update some settings + request := users_models.UsersSettings{ + IsAllowExternalRegistrations: false, + IsAllowMemberInvitations: true, + IsMemberAllowedToCreateWorkspaces: false, + } + + var response users_models.UsersSettings + test_utils.MakePutRequestAndUnmarshal( + t, + router, + "/api/v1/users/settings", + "Bearer "+testUser.Token, + request, + http.StatusOK, + &response, + ) + + // Check that settings were updated + assert.False(t, response.IsAllowExternalRegistrations) + assert.True(t, response.IsAllowMemberInvitations) + assert.False(t, response.IsMemberAllowedToCreateWorkspaces) +} + +func Test_UpdateUserSettings_WithPartialData_SettingsUpdated(t *testing.T) { + users_testing.ResetSettingsToDefaults() + router := createSettingsTestRouter() + + // Create admin user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + // Update only one setting + request := users_models.UsersSettings{ + IsAllowExternalRegistrations: false, + // Other fields will use default values + IsAllowMemberInvitations: true, + IsMemberAllowedToCreateWorkspaces: true, + } + + var response users_models.UsersSettings + test_utils.MakePutRequestAndUnmarshal( + t, + router, + "/api/v1/users/settings", + "Bearer "+testUser.Token, + request, + http.StatusOK, + &response, + ) + + // Check that only the specified setting was updated + assert.False(t, response.IsAllowExternalRegistrations) + // These should remain true (default values) + assert.True(t, response.IsAllowMemberInvitations) + assert.True(t, response.IsMemberAllowedToCreateWorkspaces) +} + +func Test_UpdateUserSettings_WhenUserIsMember_ReturnsForbidden(t *testing.T) { + users_testing.ResetSettingsToDefaults() + router := createSettingsTestRouter() + + // Create member user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + request := users_models.UsersSettings{ + IsAllowExternalRegistrations: false, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/users/settings", + "Bearer "+testUser.Token, + request, + http.StatusForbidden, + ) + assert.Contains(t, string(resp.Body), "Insufficient permissions") +} + +func Test_UpdateUserSettings_WithInvalidJSON_ReturnsBadRequest(t *testing.T) { + users_testing.ResetSettingsToDefaults() + router := createSettingsTestRouter() + + // Create admin user and get token + testUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + // Test with invalid JSON structure + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "PUT", + URL: "/api/v1/users/settings", + Body: "invalid json", + AuthToken: "Bearer " + testUser.Token, + ExpectedStatus: http.StatusBadRequest, + }) + + assert.Contains(t, string(resp.Body), "Invalid request format") +} + +func Test_UpdateUserSettings_WithoutAuth_ReturnsUnauthorized(t *testing.T) { + users_testing.ResetSettingsToDefaults() + router := createSettingsTestRouter() + + request := users_models.UsersSettings{ + IsAllowExternalRegistrations: false, + } + + test_utils.MakePutRequest( + t, + router, + "/api/v1/users/settings", + "", + request, + http.StatusUnauthorized, + ) +} diff --git a/backend/internal/features/users/controllers/user_controller.go b/backend/internal/features/users/controllers/user_controller.go new file mode 100644 index 0000000..c82c9d2 --- /dev/null +++ b/backend/internal/features/users/controllers/user_controller.go @@ -0,0 +1,343 @@ +package users_controllers + +import ( + "net/http" + + "postgresus-backend/internal/config" + user_dto "postgresus-backend/internal/features/users/dto" + user_middleware "postgresus-backend/internal/features/users/middleware" + users_services "postgresus-backend/internal/features/users/services" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +type UserController struct { + userService *users_services.UserService + signinLimiter *rate.Limiter +} + +func (c *UserController) RegisterRoutes(router *gin.RouterGroup) { + router.POST("/users/signup", c.SignUp) + router.POST("/users/signin", c.SignIn) + + // Admin password setup (no auth required) + router.GET("/users/admin/has-password", c.IsAdminHasPassword) + router.POST("/users/admin/set-password", c.SetAdminPassword) + + // OAuth callbacks + router.POST("/auth/github/callback", c.HandleGitHubOAuth) + router.POST("/auth/google/callback", c.HandleGoogleOAuth) +} + +func (c *UserController) RegisterProtectedRoutes(router *gin.RouterGroup) { + router.GET("/users/me", c.GetCurrentUser) + router.PUT("/users/me", c.UpdateUserInfo) + router.PUT("/users/change-password", c.ChangePassword) + router.POST("/users/invite", c.InviteUser) +} + +func (c *UserController) SetSignInLimiter(limiter *rate.Limiter) { + c.signinLimiter = limiter +} + +// SignUp +// @Summary Register a new user +// @Description Register a new user with email and password +// @Tags users +// @Accept json +// @Produce json +// @Param request body users_dto.SignUpRequestDTO true "User signup data" +// @Success 200 +// @Failure 400 +// @Router /users/signup [post] +func (c *UserController) SignUp(ctx *gin.Context) { + var request user_dto.SignUpRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + err := c.userService.SignUp(&request) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "User created successfully"}) +} + +// SignIn +// @Summary Authenticate a user +// @Description Authenticate a user with email and password +// @Tags users +// @Accept json +// @Produce json +// @Param request body users_dto.SignInRequestDTO true "User signin data" +// @Success 200 {object} users_dto.SignInResponseDTO +// @Failure 400 +// @Failure 429 {object} map[string]string "Rate limit exceeded" +// @Router /users/signin [post] +func (c *UserController) SignIn(ctx *gin.Context) { + // We use rate limiter to prevent brute force attacks + if !c.signinLimiter.Allow() { + ctx.JSON( + http.StatusTooManyRequests, + gin.H{"error": "Rate limit exceeded. Please try again later."}, + ) + return + } + + var request user_dto.SignInRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + response, err := c.userService.SignIn(&request) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, response) +} + +// Admin password endpoints +func (c *UserController) IsAdminHasPassword(ctx *gin.Context) { + hasPassword, err := c.userService.IsRootAdminHasPassword() + if err != nil { + ctx.JSON( + http.StatusInternalServerError, + gin.H{"error": err.Error()}, + ) + return + } + + ctx.JSON(http.StatusOK, user_dto.IsAdminHasPasswordResponseDTO{HasPassword: hasPassword}) +} + +func (c *UserController) SetAdminPassword(ctx *gin.Context) { + var request user_dto.SetAdminPasswordRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := c.userService.SetRootAdminPassword(request.Password); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "Admin password set successfully"}) +} + +// ChangePassword +// @Summary Change user password +// @Description Change the password for the currently authenticated user +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body users_dto.ChangePasswordRequestDTO true "New password data" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /users/change-password [put] +func (c *UserController) ChangePassword(ctx *gin.Context) { + user, ok := user_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var request user_dto.ChangePasswordRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + if request.NewPassword == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "New password is required"}) + return + } + + if len(request.NewPassword) < 8 { + ctx.JSON( + http.StatusBadRequest, + gin.H{"error": "New password must be at least 8 characters long"}, + ) + return + } + + if err := c.userService.ChangeUserPassword(user.ID, request.NewPassword); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "Password changed successfully"}) +} + +// InviteUser +// @Summary Invite a new user +// @Description Invite a new user to the system with optional workspace assignment +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body users_dto.InviteUserRequestDTO true "User invitation data" +// @Success 200 {object} users_dto.InviteUserResponseDTO +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /users/invite [post] +func (c *UserController) InviteUser(ctx *gin.Context) { + user, ok := user_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var request user_dto.InviteUserRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + response, err := c.userService.InviteUser(&request, user) + if err != nil { + if err.Error() == "insufficient permissions to invite users" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, response) +} + +// GetCurrentUser +// @Summary Get current user profile +// @Description Get the profile information of the currently authenticated user +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} users_dto.UserProfileResponseDTO +// @Failure 401 {object} map[string]string +// @Router /users/me [get] +func (c *UserController) GetCurrentUser(ctx *gin.Context) { + user, ok := user_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + profile := c.userService.GetCurrentUserProfile(user) + ctx.JSON(http.StatusOK, profile) +} + +// UpdateUserInfo +// @Summary Update current user information +// @Description Update name and/or email for the currently authenticated user +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body users_dto.UpdateUserInfoRequestDTO true "User info update data" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /users/me [put] +func (c *UserController) UpdateUserInfo(ctx *gin.Context) { + user, ok := user_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var request user_dto.UpdateUserInfoRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + if request.Name == nil && request.Email == nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) + return + } + + if err := c.userService.UpdateUserInfo(user.ID, &request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "User info updated successfully"}) +} + +// HandleGitHubOAuth +// @Summary Handle GitHub OAuth callback +// @Description Exchange GitHub authorization code for JWT token +// @Tags auth +// @Accept json +// @Produce json +// @Param request body users_dto.OAuthCallbackRequestDTO true "OAuth callback data" +// @Success 200 {object} users_dto.OAuthCallbackResponseDTO +// @Failure 400 {object} map[string]string +// @Failure 501 {object} map[string]string +// @Router /auth/github/callback [post] +func (c *UserController) HandleGitHubOAuth(ctx *gin.Context) { + env := config.GetEnv() + if env.GitHubClientID == "" || env.GitHubClientSecret == "" { + ctx.JSON(http.StatusNotImplemented, gin.H{"error": "GitHub OAuth is not configured"}) + return + } + + var request user_dto.OAuthCallbackRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + response, err := c.userService.HandleGitHubOAuth(request.Code, request.RedirectUri) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, response) +} + +// HandleGoogleOAuth +// @Summary Handle Google OAuth callback +// @Description Exchange Google authorization code for JWT token +// @Tags auth +// @Accept json +// @Produce json +// @Param request body users_dto.OAuthCallbackRequestDTO true "OAuth callback data" +// @Success 200 {object} users_dto.OAuthCallbackResponseDTO +// @Failure 400 {object} map[string]string +// @Failure 501 {object} map[string]string +// @Router /auth/google/callback [post] +func (c *UserController) HandleGoogleOAuth(ctx *gin.Context) { + env := config.GetEnv() + if env.GoogleClientID == "" || env.GoogleClientSecret == "" { + ctx.JSON(http.StatusNotImplemented, gin.H{"error": "Google OAuth is not configured"}) + return + } + + var request user_dto.OAuthCallbackRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + response, err := c.userService.HandleGoogleOAuth(request.Code, request.RedirectUri) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, response) +} diff --git a/backend/internal/features/users/controllers/user_controller_test.go b/backend/internal/features/users/controllers/user_controller_test.go new file mode 100644 index 0000000..6318ee5 --- /dev/null +++ b/backend/internal/features/users/controllers/user_controller_test.go @@ -0,0 +1,1148 @@ +package users_controllers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + users_dto "postgresus-backend/internal/features/users/dto" + users_enums "postgresus-backend/internal/features/users/enums" + users_services "postgresus-backend/internal/features/users/services" + users_testing "postgresus-backend/internal/features/users/testing" + test_utils "postgresus-backend/internal/util/testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" +) + +func Test_SignUpUser_WithValidData_UserCreated(t *testing.T) { + router := createUserTestRouter() + + request := users_dto.SignUpRequestDTO{ + Email: "test" + uuid.New().String() + "@example.com", + Password: "testpassword123", + Name: "Test User", + } + + test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", request, http.StatusOK) +} + +func Test_SignUpUser_WithInvalidJSON_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + + // Test with invalid JSON structure + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "POST", + URL: "/api/v1/users/signup", + Body: "invalid json", + ExpectedStatus: http.StatusBadRequest, + }) + + assert.Contains(t, string(resp.Body), "Invalid request format") +} + +func Test_SignUpUser_WithDuplicateEmail_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + email := "duplicate" + uuid.New().String() + "@example.com" + + request := users_dto.SignUpRequestDTO{ + Email: email, + Password: "testpassword123", + Name: "Test User", + } + + // First signup + test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", request, http.StatusOK) + + // Second signup with same email + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/signup", + "", + request, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "already exists") +} + +func Test_SignUpUser_WithValidationErrors_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + + testCases := []struct { + name string + request users_dto.SignUpRequestDTO + }{ + { + name: "missing email", + request: users_dto.SignUpRequestDTO{ + Password: "testpassword123", + Name: "Test User", + }, + }, + { + name: "missing password", + request: users_dto.SignUpRequestDTO{ + Email: "test@example.com", + Name: "Test User", + }, + }, + { + name: "short password", + request: users_dto.SignUpRequestDTO{ + Email: "test@example.com", + Password: "short", + Name: "Test User", + }, + }, + { + name: "missing name", + request: users_dto.SignUpRequestDTO{ + Email: "test@example.com", + Password: "testpassword123", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/signup", + "", + tc.request, + http.StatusBadRequest, + ) + }) + } +} + +func Test_SignInUser_WithValidCredentials_ReturnsToken(t *testing.T) { + router := createUserTestRouter() + email := "signin" + uuid.New().String() + "@example.com" + password := "testpassword123" + + // First create a user + signupRequest := users_dto.SignUpRequestDTO{ + Email: email, + Password: password, + Name: "Test User", + } + test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK) + + // Now sign in + signinRequest := users_dto.SignInRequestDTO{ + Email: email, + Password: password, + } + + var response users_dto.SignInResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/users/signin", + "", + signinRequest, + http.StatusOK, + &response, + ) + + assert.NotEmpty(t, response.Token) + assert.NotEqual(t, uuid.Nil, response.UserID) +} + +func Test_SignInUser_WithWrongPassword_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + email := "signin2" + uuid.New().String() + "@example.com" + + // First create a user + signupRequest := users_dto.SignUpRequestDTO{ + Email: email, + Password: "testpassword123", + Name: "Test User", + } + test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK) + + // Now sign in with wrong password + signinRequest := users_dto.SignInRequestDTO{ + Email: email, + Password: "wrongpassword", + } + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/signin", + "", + signinRequest, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "password is incorrect") +} + +func Test_SignInUser_WithNonExistentUser_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + + signinRequest := users_dto.SignInRequestDTO{ + Email: "nonexistent" + uuid.New().String() + "@example.com", + Password: "testpassword123", + } + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/signin", + "", + signinRequest, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "does not exist") +} + +func Test_SignInUser_WithInvalidJSON_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + + // Test with invalid JSON structure + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "POST", + URL: "/api/v1/users/signin", + Body: "invalid json", + ExpectedStatus: http.StatusBadRequest, + }) + + assert.Contains(t, string(resp.Body), "Invalid request format") +} + +func Test_CheckAdminHasPassword_WhenAdminHasNoPassword_ReturnsFalse(t *testing.T) { + router := createUserTestRouter() + + users_testing.RecreateInitialAdmin() + + var response users_dto.IsAdminHasPasswordResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users/admin/has-password", + "", + http.StatusOK, + &response, + ) + + assert.False(t, response.HasPassword) +} + +func Test_SetAdminPassword_WithValidPassword_PasswordSet(t *testing.T) { + router := createUserTestRouter() + + users_testing.RecreateInitialAdmin() + + request := users_dto.SetAdminPasswordRequestDTO{ + Password: "adminpassword123", + } + + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/admin/set-password", + "", + request, + http.StatusOK, + ) + + // Now check that admin has password + var hasPasswordResponse users_dto.IsAdminHasPasswordResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users/admin/has-password", + "", + http.StatusOK, + &hasPasswordResponse, + ) + + assert.True(t, hasPasswordResponse.HasPassword) +} + +func Test_SetAdminPassword_WithInvalidPassword_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + + testCases := []struct { + name string + password string + }{ + { + name: "short password", + password: "short", + }, + { + name: "empty password", + password: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + request := users_dto.SetAdminPasswordRequestDTO{ + Password: tc.password, + } + + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/admin/set-password", + "", + request, + http.StatusBadRequest, + ) + }) + } +} + +func Test_SetAdminPassword_WithInvalidJSON_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + + // Test with invalid JSON structure + test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "POST", + URL: "/api/v1/users/admin/set-password", + Body: "invalid json", + ExpectedStatus: http.StatusBadRequest, + }) +} + +func Test_ChangeUserPassword_WithValidData_PasswordChanged(t *testing.T) { + router := createUserTestRouter() + email := "changepass" + uuid.New().String() + "@example.com" + oldPassword := "oldpassword123" + newPassword := "newpassword123" + + // Create user via signup + signupRequest := users_dto.SignUpRequestDTO{ + Email: email, + Password: oldPassword, + Name: "Test User", + } + test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK) + + // Sign in to get token + signinRequest := users_dto.SignInRequestDTO{ + Email: email, + Password: oldPassword, + } + var signinResponse users_dto.SignInResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/users/signin", + "", + signinRequest, + http.StatusOK, + &signinResponse, + ) + + // Change password + changePasswordRequest := users_dto.ChangePasswordRequestDTO{ + NewPassword: newPassword, + } + + test_utils.MakePutRequest( + t, + router, + "/api/v1/users/change-password", + "Bearer "+signinResponse.Token, + changePasswordRequest, + http.StatusOK, + ) + + // Verify old password no longer works + oldSigninRequest := users_dto.SignInRequestDTO{ + Email: email, + Password: oldPassword, + } + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/signin", + "", + oldSigninRequest, + http.StatusBadRequest, + ) + + // Verify new password works + newSigninRequest := users_dto.SignInRequestDTO{ + Email: email, + Password: newPassword, + } + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/signin", + "", + newSigninRequest, + http.StatusOK, + ) +} + +func Test_ChangeUserPassword_WithoutAuth_ReturnsUnauthorized(t *testing.T) { + router := createUserTestRouter() + + request := users_dto.ChangePasswordRequestDTO{ + NewPassword: "newpassword123", + } + + test_utils.MakePutRequest( + t, + router, + "/api/v1/users/change-password", + "", + request, + http.StatusUnauthorized, + ) +} + +func Test_ChangeUserPassword_WithInvalidJSON_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + testUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + // Test with invalid JSON structure + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "PUT", + URL: "/api/v1/users/change-password", + Body: "invalid json", + AuthToken: "Bearer " + testUser.Token, + ExpectedStatus: http.StatusBadRequest, + }) + + assert.Contains(t, string(resp.Body), "Invalid request format") +} + +func Test_ChangeUserPassword_WithValidationErrors_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + testUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + testCases := []struct { + name string + request users_dto.ChangePasswordRequestDTO + }{ + { + name: "missing new password", + request: users_dto.ChangePasswordRequestDTO{}, + }, + { + name: "short new password", + request: users_dto.ChangePasswordRequestDTO{ + NewPassword: "short", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + test_utils.MakePutRequest( + t, + router, + "/api/v1/users/change-password", + "Bearer "+testUser.Token, + tc.request, + http.StatusBadRequest, + ) + }) + } +} + +func Test_InviteUser_WhenUserIsAdmin_UserInvited(t *testing.T) { + router := createUserTestRouter() + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + workspaceID := uuid.New() + workspaceRole := users_enums.WorkspaceRoleMember + + request := users_dto.InviteUserRequestDTO{ + Email: "invited" + uuid.New().String() + "@example.com", + IntendedWorkspaceID: &workspaceID, + IntendedWorkspaceRole: &workspaceRole, + } + + var response users_dto.InviteUserResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/users/invite", + "Bearer "+adminUser.Token, + request, + http.StatusOK, + &response, + ) + + assert.Equal(t, request.Email, response.Email) + assert.Equal(t, request.IntendedWorkspaceID, response.IntendedWorkspaceID) + assert.Equal(t, request.IntendedWorkspaceRole, response.IntendedWorkspaceRole) + assert.NotEqual(t, uuid.Nil, response.ID) +} + +func Test_InviteUser_WithoutAuth_ReturnsUnauthorized(t *testing.T) { + router := createUserTestRouter() + + request := users_dto.InviteUserRequestDTO{ + Email: "invited@example.com", + } + + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/invite", + "", + request, + http.StatusUnauthorized, + ) +} + +func Test_InviteUser_WithoutPermission_ReturnsForbidden(t *testing.T) { + router := createUserTestRouter() + defer users_testing.ResetSettingsToDefaults() + + memberUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + uniqueID := uuid.New().String()[:8] + request := users_dto.InviteUserRequestDTO{ + Email: fmt.Sprintf("invited_%s@example.com", uniqueID), + } + + users_testing.DisableMemberInvitations() + + settingsService := users_services.GetSettingsService() + settings, err := settingsService.GetSettings() + assert.NoError(t, err) + + if settings.IsAllowMemberInvitations { + t.Fatal( + "RACE CONDITION DETECTED: Member invitations should be disabled but were enabled by another test", + ) + } + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/invite", + "Bearer "+memberUser.Token, + request, + http.StatusForbidden, + ) + assert.Contains(t, string(resp.Body), "insufficient permissions") +} + +func Test_InviteUser_WithInvalidJSON_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + // Test with invalid JSON structure + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "POST", + URL: "/api/v1/users/invite", + Body: "invalid json", + AuthToken: "Bearer " + adminUser.Token, + ExpectedStatus: http.StatusBadRequest, + }) + + assert.Contains(t, string(resp.Body), "Invalid request format") +} + +func Test_InviteUser_WithValidationErrors_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + testCases := []struct { + name string + request users_dto.InviteUserRequestDTO + }{ + { + name: "missing email", + request: users_dto.InviteUserRequestDTO{ + IntendedWorkspaceID: &uuid.UUID{}, + }, + }, + { + name: "invalid email", + request: users_dto.InviteUserRequestDTO{ + Email: "invalid-email", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/invite", + "Bearer "+adminUser.Token, + tc.request, + http.StatusBadRequest, + ) + }) + } +} + +func Test_InviteUser_WithDuplicateEmail_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + email := "duplicate-invite" + uuid.New().String() + "@example.com" + + request := users_dto.InviteUserRequestDTO{ + Email: email, + } + + // First invitation + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/invite", + "Bearer "+adminUser.Token, + request, + http.StatusOK, + ) + + // Second invitation with same email + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/users/invite", + "Bearer "+adminUser.Token, + request, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "already exists") +} + +func Test_UpdateUserInfo_WithValidName_NameUpdated(t *testing.T) { + router := createUserTestRouter() + testUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + newName := "Updated Name" + request := users_dto.UpdateUserInfoRequestDTO{ + Name: &newName, + } + + test_utils.MakePutRequest( + t, + router, + "/api/v1/users/me", + "Bearer "+testUser.Token, + request, + http.StatusOK, + ) + + var profile users_dto.UserProfileResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users/me", + "Bearer "+testUser.Token, + http.StatusOK, + &profile, + ) + + assert.Equal(t, "Updated Name", profile.Name) +} + +func Test_UpdateUserInfo_WithValidEmail_EmailUpdated(t *testing.T) { + router := createUserTestRouter() + testUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + newEmail := "newemail" + uuid.New().String() + "@example.com" + request := users_dto.UpdateUserInfoRequestDTO{ + Email: &newEmail, + } + + test_utils.MakePutRequest( + t, + router, + "/api/v1/users/me", + "Bearer "+testUser.Token, + request, + http.StatusOK, + ) + + var profile users_dto.UserProfileResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/users/me", + "Bearer "+testUser.Token, + http.StatusOK, + &profile, + ) + + assert.Equal(t, newEmail, profile.Email) +} + +func Test_UpdateUserInfo_WithTakenEmail_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + user1 := users_testing.CreateTestUser(users_enums.UserRoleMember) + user2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + + request := users_dto.UpdateUserInfoRequestDTO{ + Email: &user2.Email, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/users/me", + "Bearer "+user1.Token, + request, + http.StatusBadRequest, + ) + + assert.Contains(t, string(resp.Body), "already taken") +} + +func Test_UpdateUserInfo_WhenAdminTriesToChangeEmail_ReturnsBadRequest(t *testing.T) { + router := createUserTestRouter() + adminUser := users_testing.ReacreateInitAdminAndGetAccess() + + newEmail := "newemail@example.com" + request := users_dto.UpdateUserInfoRequestDTO{ + Email: &newEmail, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/users/me", + "Bearer "+adminUser.Token, + request, + http.StatusBadRequest, + ) + + assert.Contains(t, string(resp.Body), "admin email cannot be changed") +} + +func Test_GitHubOAuth_WithValidCode_ReturnsToken(t *testing.T) { + testID := uuid.New().String()[:8] + testEmail := "github-user-" + testID + "@example.com" + testOAuthID := int64(uuid.New().ID()) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login/oauth/access_token" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": "mock-access-token", + "token_type": "bearer", + }) + return + } + if r.URL.Path == "/user" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": testOAuthID, + "email": testEmail, + "name": "GitHub Test User", + "login": "githubtest", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockServer.Close() + + endpoint := oauth2.Endpoint{ + AuthURL: mockServer.URL + "/login/oauth/authorize", + TokenURL: mockServer.URL + "/login/oauth/access_token", + } + + userService := users_services.GetUserService() + response, err := userService.HandleGitHubOAuthWithMockEndpoint( + "test-code", + "http://localhost:3000/auth/callback", + endpoint, + mockServer.URL+"/user", + ) + + assert.NoError(t, err) + assert.NotEmpty(t, response.Token) + assert.Equal(t, testEmail, response.Email) + assert.True(t, response.IsNewUser) +} + +func Test_GitHubOAuth_WithExistingEmail_LinksAccount(t *testing.T) { + testID := uuid.New().String()[:8] + email := "existing-" + testID + "@example.com" + testOAuthID := int64(uuid.New().ID()) + + router := createUserTestRouter() + signupRequest := users_dto.SignUpRequestDTO{ + Email: email, + Password: "testpassword123", + Name: "Existing User", + } + test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login/oauth/access_token" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": "mock-access-token", + "token_type": "bearer", + }) + return + } + if r.URL.Path == "/user" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": testOAuthID, + "email": email, + "name": "GitHub Test User", + "login": "githubtest", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockServer.Close() + + endpoint := oauth2.Endpoint{ + AuthURL: mockServer.URL + "/login/oauth/authorize", + TokenURL: mockServer.URL + "/login/oauth/access_token", + } + + userService := users_services.GetUserService() + response, err := userService.HandleGitHubOAuthWithMockEndpoint( + "test-code", + "http://localhost:3000/auth/callback", + endpoint, + mockServer.URL+"/user", + ) + + assert.NoError(t, err) + assert.NotEmpty(t, response.Token) + assert.Equal(t, email, response.Email) + assert.False(t, response.IsNewUser) +} + +func Test_GitHubOAuth_WithInvitedUser_ActivatesUser(t *testing.T) { + router := createUserTestRouter() + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testID := uuid.New().String()[:8] + email := "invited-" + testID + "@example.com" + testOAuthID := int64(uuid.New().ID()) + + inviteRequest := users_dto.InviteUserRequestDTO{ + Email: email, + } + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/invite", + "Bearer "+adminUser.Token, + inviteRequest, + http.StatusOK, + ) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login/oauth/access_token" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": "mock-access-token", + "token_type": "bearer", + }) + return + } + if r.URL.Path == "/user" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": testOAuthID, + "email": email, + "name": "GitHub Test User", + "login": "githubtest", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockServer.Close() + + endpoint := oauth2.Endpoint{ + AuthURL: mockServer.URL + "/login/oauth/authorize", + TokenURL: mockServer.URL + "/login/oauth/access_token", + } + + userService := users_services.GetUserService() + response, err := userService.HandleGitHubOAuthWithMockEndpoint( + "test-code", + "http://localhost:3000/auth/callback", + endpoint, + mockServer.URL+"/user", + ) + + assert.NoError(t, err) + assert.NotEmpty(t, response.Token) + assert.Equal(t, email, response.Email) + assert.False(t, response.IsNewUser) +} + +func Test_GitHubOAuth_WithNoPublicEmail_FetchesFromEmailsEndpoint(t *testing.T) { + testID := uuid.New().String()[:8] + testEmail := "private-email-" + testID + "@example.com" + testOAuthID := int64(uuid.New().ID()) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login/oauth/access_token" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": "mock-access-token", + "token_type": "bearer", + }) + return + } + if r.URL.Path == "/user" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": testOAuthID, + "email": "", + "name": "GitHub Test User", + "login": "githubtest", + }) + return + } + if r.URL.Path == "/user/emails" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "email": testEmail, + "primary": true, + "verified": true, + }, + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockServer.Close() + + endpoint := oauth2.Endpoint{ + AuthURL: mockServer.URL + "/login/oauth/authorize", + TokenURL: mockServer.URL + "/login/oauth/access_token", + } + + userService := users_services.GetUserService() + response, err := userService.HandleGitHubOAuthWithMockEndpoint( + "test-code", + "http://localhost:3000/auth/callback", + endpoint, + mockServer.URL+"/user", + ) + + assert.NoError(t, err) + assert.NotEmpty(t, response.Token) + assert.Equal(t, testEmail, response.Email) + assert.True(t, response.IsNewUser) +} + +func Test_GitHubOAuth_WhenRegistrationDisabled_ReturnsBadRequest(t *testing.T) { + defer users_testing.ResetSettingsToDefaults() + users_testing.DisableExternalRegistrations() + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login/oauth/access_token" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": "mock-access-token", + "token_type": "bearer", + }) + return + } + if r.URL.Path == "/user" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 99999, + "email": "new-user-" + uuid.New().String()[:8] + "@example.com", + "name": "GitHub Test User", + "login": "githubtest", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockServer.Close() + + endpoint := oauth2.Endpoint{ + AuthURL: mockServer.URL + "/login/oauth/authorize", + TokenURL: mockServer.URL + "/login/oauth/access_token", + } + + userService := users_services.GetUserService() + response, err := userService.HandleGitHubOAuthWithMockEndpoint( + "test-code", + "http://localhost:3000/auth/callback", + endpoint, + mockServer.URL+"/user", + ) + + assert.Error(t, err) + assert.Nil(t, response) + assert.Contains(t, err.Error(), "registration is disabled") +} + +func Test_GoogleOAuth_WithValidCode_ReturnsToken(t *testing.T) { + testID := uuid.New().String()[:8] + testEmail := "google-user-" + testID + "@example.com" + testOAuthID := "google-" + testID + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": "mock-access-token", + "token_type": "Bearer", + }) + return + } + if r.URL.Path == "/userinfo" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": testOAuthID, + "email": testEmail, + "name": "Google Test User", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockServer.Close() + + endpoint := oauth2.Endpoint{ + AuthURL: mockServer.URL + "/auth", + TokenURL: mockServer.URL + "/token", + } + + userService := users_services.GetUserService() + response, err := userService.HandleGoogleOAuthWithMockEndpoint( + "test-code", + "http://localhost:3000/auth/callback", + endpoint, + mockServer.URL+"/userinfo", + ) + + assert.NoError(t, err) + assert.NotEmpty(t, response.Token) + assert.Equal(t, testEmail, response.Email) + assert.True(t, response.IsNewUser) +} + +func Test_GoogleOAuth_WithExistingEmail_LinksAccount(t *testing.T) { + testID := uuid.New().String()[:8] + email := "existing-google-" + testID + "@example.com" + testOAuthID := "google-" + testID + "-456" + + router := createUserTestRouter() + signupRequest := users_dto.SignUpRequestDTO{ + Email: email, + Password: "testpassword123", + Name: "Existing User", + } + test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": "mock-access-token", + "token_type": "Bearer", + }) + return + } + if r.URL.Path == "/userinfo" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": testOAuthID, + "email": email, + "name": "Google Test User", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockServer.Close() + + endpoint := oauth2.Endpoint{ + AuthURL: mockServer.URL + "/auth", + TokenURL: mockServer.URL + "/token", + } + + userService := users_services.GetUserService() + response, err := userService.HandleGoogleOAuthWithMockEndpoint( + "test-code", + "http://localhost:3000/auth/callback", + endpoint, + mockServer.URL+"/userinfo", + ) + + assert.NoError(t, err) + assert.NotEmpty(t, response.Token) + assert.Equal(t, email, response.Email) + assert.False(t, response.IsNewUser) +} + +func Test_GoogleOAuth_WithInvitedUser_ActivatesUser(t *testing.T) { + router := createUserTestRouter() + adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testID := uuid.New().String()[:8] + email := "invited-google-" + testID + "@example.com" + testOAuthID := "google-" + testID + "-789" + + inviteRequest := users_dto.InviteUserRequestDTO{ + Email: email, + } + test_utils.MakePostRequest( + t, + router, + "/api/v1/users/invite", + "Bearer "+adminUser.Token, + inviteRequest, + http.StatusOK, + ) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": "mock-access-token", + "token_type": "Bearer", + }) + return + } + if r.URL.Path == "/userinfo" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": testOAuthID, + "email": email, + "name": "Google Test User", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockServer.Close() + + endpoint := oauth2.Endpoint{ + AuthURL: mockServer.URL + "/auth", + TokenURL: mockServer.URL + "/token", + } + + userService := users_services.GetUserService() + response, err := userService.HandleGoogleOAuthWithMockEndpoint( + "test-code", + "http://localhost:3000/auth/callback", + endpoint, + mockServer.URL+"/userinfo", + ) + + assert.NoError(t, err) + assert.NotEmpty(t, response.Token) + assert.Equal(t, email, response.Email) + assert.False(t, response.IsNewUser) +} diff --git a/backend/internal/features/users/di.go b/backend/internal/features/users/di.go deleted file mode 100644 index cea7ae6..0000000 --- a/backend/internal/features/users/di.go +++ /dev/null @@ -1,26 +0,0 @@ -package users - -import ( - user_repositories "postgresus-backend/internal/features/users/repositories" - - "golang.org/x/time/rate" -) - -var secretKeyRepository = &user_repositories.SecretKeyRepository{} -var userRepository = &user_repositories.UserRepository{} -var userService = &UserService{ - userRepository, - secretKeyRepository, -} -var userController = &UserController{ - userService, - rate.NewLimiter(rate.Limit(3), 3), // 3 RPS with burst of 3 -} - -func GetUserService() *UserService { - return userService -} - -func GetUserController() *UserController { - return userController -} diff --git a/backend/internal/features/users/dto.go b/backend/internal/features/users/dto.go deleted file mode 100644 index c192ac0..0000000 --- a/backend/internal/features/users/dto.go +++ /dev/null @@ -1,18 +0,0 @@ -package users - -import "github.com/google/uuid" - -type SignUpRequest struct { - Email string `json:"email" validate:"required,email"` - Password string `json:"password" validate:"required,min=8"` -} - -type SignInRequest struct { - Email string `json:"email" validate:"required,email"` - Password string `json:"password" validate:"required"` -} - -type SignInResponse struct { - UserID uuid.UUID `json:"userId"` - Token string `json:"token"` -} diff --git a/backend/internal/features/users/dto/dto.go b/backend/internal/features/users/dto/dto.go new file mode 100644 index 0000000..69574f3 --- /dev/null +++ b/backend/internal/features/users/dto/dto.go @@ -0,0 +1,94 @@ +package users_dto + +import ( + "time" + + users_enums "postgresus-backend/internal/features/users/enums" + + "github.com/google/uuid" +) + +type SignUpRequestDTO struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required,min=8"` + Name string `json:"name" binding:"required"` +} + +type SignInRequestDTO struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type SignInResponseDTO struct { + UserID uuid.UUID `json:"userId"` + Email string `json:"email"` + Token string `json:"token"` +} + +type SetAdminPasswordRequestDTO struct { + Password string `json:"password" binding:"required,min=8"` +} + +type IsAdminHasPasswordResponseDTO struct { + HasPassword bool `json:"hasPassword"` +} + +type ChangePasswordRequestDTO struct { + NewPassword string `json:"newPassword" binding:"required,min=8"` +} + +type UpdateUserInfoRequestDTO struct { + Name *string `json:"name"` + Email *string `json:"email" binding:"omitempty,email"` +} + +type InviteUserRequestDTO struct { + Email string `json:"email" binding:"required,email"` + IntendedWorkspaceID *uuid.UUID `json:"intendedWorkspaceId"` + IntendedWorkspaceRole *users_enums.WorkspaceRole `json:"intendedWorkspaceRole"` +} + +type InviteUserResponseDTO struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + IntendedWorkspaceID *uuid.UUID `json:"intendedWorkspaceId"` + IntendedWorkspaceRole *users_enums.WorkspaceRole `json:"intendedWorkspaceRole"` + CreatedAt time.Time `json:"createdAt"` +} + +type UserProfileResponseDTO struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role users_enums.UserRole `json:"role"` + IsActive bool `json:"isActive"` + CreatedAt time.Time `json:"createdAt"` +} + +type ListUsersResponseDTO struct { + Users []UserProfileResponseDTO `json:"users"` + Total int64 `json:"total"` +} + +type ChangeUserRoleRequestDTO struct { + Role users_enums.UserRole `json:"role" binding:"required"` +} + +type ListUsersRequestDTO struct { + Limit int `form:"limit" json:"limit"` + Offset int `form:"offset" json:"offset"` + BeforeDate *time.Time `form:"beforeDate" json:"beforeDate"` + Query string `form:"query" json:"query"` +} + +type OAuthCallbackRequestDTO struct { + Code string `json:"code" binding:"required"` + RedirectUri string `json:"redirectUri" binding:"required"` +} + +type OAuthCallbackResponseDTO struct { + UserID uuid.UUID `json:"userId"` + Email string `json:"email"` + Token string `json:"token"` + IsNewUser bool `json:"isNewUser"` +} diff --git a/backend/internal/features/users/enums/user_role.go b/backend/internal/features/users/enums/user_role.go index 29e8c1f..a99a9ad 100644 --- a/backend/internal/features/users/enums/user_role.go +++ b/backend/internal/features/users/enums/user_role.go @@ -1,7 +1,17 @@ -package user_enums +package users_enums type UserRole string const ( - UserRoleAdmin UserRole = "ADMIN" + UserRoleAdmin UserRole = "ADMIN" + UserRoleMember UserRole = "MEMBER" ) + +func (r UserRole) IsValid() bool { + switch r { + case UserRoleAdmin, UserRoleMember: + return true + default: + return false + } +} diff --git a/backend/internal/features/users/enums/user_status.go b/backend/internal/features/users/enums/user_status.go new file mode 100644 index 0000000..3e3ee8a --- /dev/null +++ b/backend/internal/features/users/enums/user_status.go @@ -0,0 +1,9 @@ +package users_enums + +type UserStatus string + +const ( + UserStatusInvited UserStatus = "INVITED" + UserStatusActive UserStatus = "ACTIVE" + UserStatusInactive UserStatus = "INACTIVE" +) diff --git a/backend/internal/features/users/enums/workspace_role.go b/backend/internal/features/users/enums/workspace_role.go new file mode 100644 index 0000000..a148de1 --- /dev/null +++ b/backend/internal/features/users/enums/workspace_role.go @@ -0,0 +1,20 @@ +package users_enums + +type WorkspaceRole string + +const ( + WorkspaceRoleOwner WorkspaceRole = "WORKSPACE_OWNER" + WorkspaceRoleAdmin WorkspaceRole = "WORKSPACE_ADMIN" + WorkspaceRoleMember WorkspaceRole = "WORKSPACE_MEMBER" + WorkspaceRoleViewer WorkspaceRole = "WORKSPACE_VIEWER" +) + +// IsValid validates the WorkspaceRole +func (r WorkspaceRole) IsValid() bool { + switch r { + case WorkspaceRoleOwner, WorkspaceRoleAdmin, WorkspaceRoleMember, WorkspaceRoleViewer: + return true + default: + return false + } +} diff --git a/backend/internal/features/users/interfaces/interfaces.go b/backend/internal/features/users/interfaces/interfaces.go new file mode 100644 index 0000000..b30e885 --- /dev/null +++ b/backend/internal/features/users/interfaces/interfaces.go @@ -0,0 +1,9 @@ +package users_interfaces + +import ( + "github.com/google/uuid" +) + +type AuditLogWriter interface { + WriteAuditLog(message string, userID *uuid.UUID, workspaceID *uuid.UUID) +} diff --git a/backend/internal/features/users/middleware/middleware.go b/backend/internal/features/users/middleware/middleware.go new file mode 100644 index 0000000..0b99909 --- /dev/null +++ b/backend/internal/features/users/middleware/middleware.go @@ -0,0 +1,75 @@ +package users_middleware + +import ( + "net/http" + users_enums "postgresus-backend/internal/features/users/enums" + users_models "postgresus-backend/internal/features/users/models" + users_services "postgresus-backend/internal/features/users/services" + + "github.com/gin-gonic/gin" +) + +// AuthMiddleware validates JWT token and adds user to context +func AuthMiddleware(userService *users_services.UserService) gin.HandlerFunc { + return func(ctx *gin.Context) { + token := ctx.GetHeader("Authorization") + if token == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization token required"}) + ctx.Abort() + return + } + + // Remove "Bearer " prefix if present + if len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + + user, err := userService.GetUserFromToken(token) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + ctx.Abort() + return + } + + ctx.Set("user", user) + ctx.Next() + } +} + +func RequireRole(requiredRole users_enums.UserRole) gin.HandlerFunc { + return func(ctx *gin.Context) { + userInterface, exists := ctx.Get("user") + if !exists { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + ctx.Abort() + return + } + + user, ok := userInterface.(*users_models.User) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user context"}) + ctx.Abort() + return + } + + if user.Role != requiredRole { + ctx.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) + ctx.Abort() + return + } + + ctx.Next() + } +} + +// GetUserFromContext helper function to extract user from gin context +func GetUserFromContext(ctx *gin.Context) (*users_models.User, bool) { + userInterface, exists := ctx.Get("user") + if !exists { + return nil, false + } + + user, ok := userInterface.(*users_models.User) + + return user, ok +} diff --git a/backend/internal/features/users/models/secret_key.go b/backend/internal/features/users/models/secret_key.go index dee7264..031c2f8 100644 --- a/backend/internal/features/users/models/secret_key.go +++ b/backend/internal/features/users/models/secret_key.go @@ -1,7 +1,7 @@ package users_models type SecretKey struct { - Secret string `gorm:"column:secret;uniqueIndex;not null"` + Secret string `gorm:"column:secret"` } func (SecretKey) TableName() string { diff --git a/backend/internal/features/users/models/user.go b/backend/internal/features/users/models/user.go index 10ab84b..1fb67f9 100644 --- a/backend/internal/features/users/models/user.go +++ b/backend/internal/features/users/models/user.go @@ -1,21 +1,57 @@ package users_models import ( - user_enums "postgresus-backend/internal/features/users/enums" + users_enums "postgresus-backend/internal/features/users/enums" "time" "github.com/google/uuid" ) type User struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` - Email string `json:"email" gorm:"uniqueIndex;not null"` - HashedPassword string `json:"-" gorm:"not null"` - PasswordCreationTime time.Time `json:"-" gorm:"not null"` - CreatedAt time.Time `json:"createdAt" gorm:"not null;default:now()"` - Role user_enums.UserRole `json:"role" gorm:"type:text;not null"` + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + HashedPassword *string `json:"-" gorm:"column:hashed_password"` + PasswordCreationTime time.Time `json:"-" gorm:"column:password_creation_time"` + Role users_enums.UserRole `json:"role"` + Status users_enums.UserStatus `json:"status"` + GitHubOAuthID *string `json:"-" gorm:"column:github_oauth_id"` + GoogleOAuthID *string `json:"-" gorm:"column:google_oauth_id"` + CreatedAt time.Time `json:"createdAt"` } func (User) TableName() string { return "users" } + +// Permission methods +func (u *User) CanInviteUsers(settings *UsersSettings) bool { + if u.Role == users_enums.UserRoleAdmin { + return true + } + + return u.Role == users_enums.UserRoleMember && settings.IsAllowMemberInvitations +} + +func (u *User) CanManageUsers() bool { + return u.Role == users_enums.UserRoleAdmin +} + +func (u *User) CanUpdateSettings() bool { + return u.Role == users_enums.UserRoleAdmin +} + +func (u *User) CanCreateWorkspaces(settings *UsersSettings) bool { + if u.Role == users_enums.UserRoleAdmin { + return true + } + return u.Role == users_enums.UserRoleMember && settings.IsMemberAllowedToCreateWorkspaces +} + +func (u *User) IsActiveUser() bool { + return u.Status == users_enums.UserStatusActive +} + +func (u *User) HasPassword() bool { + return u.HashedPassword != nil && *u.HashedPassword != "" +} diff --git a/backend/internal/features/users/models/users_settings.go b/backend/internal/features/users/models/users_settings.go new file mode 100644 index 0000000..5e75d8a --- /dev/null +++ b/backend/internal/features/users/models/users_settings.go @@ -0,0 +1,17 @@ +package users_models + +import "github.com/google/uuid" + +type UsersSettings struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + // means that any user can register via sign up form without invitation + IsAllowExternalRegistrations bool `json:"isAllowExternalRegistrations" gorm:"column:is_allow_external_registrations"` + // means that any user with role MEMBER can invite other users + IsAllowMemberInvitations bool `json:"isAllowMemberInvitations" gorm:"column:is_allow_member_invitations"` + // means that any user with role MEMBER can create their own workspaces + IsMemberAllowedToCreateWorkspaces bool `json:"isMemberAllowedToCreateWorkspaces" gorm:"column:is_member_allowed_to_create_workspaces"` +} + +func (UsersSettings) TableName() string { + return "users_settings" +} diff --git a/backend/internal/features/users/repositories/secret_key_repository.go b/backend/internal/features/users/repositories/secret_key_repository.go index 492a754..865a271 100644 --- a/backend/internal/features/users/repositories/secret_key_repository.go +++ b/backend/internal/features/users/repositories/secret_key_repository.go @@ -1,4 +1,4 @@ -package user_repositories +package users_repositories import ( "errors" diff --git a/backend/internal/features/users/repositories/user_repository.go b/backend/internal/features/users/repositories/user_repository.go index ff95459..8822b60 100644 --- a/backend/internal/features/users/repositories/user_repository.go +++ b/backend/internal/features/users/repositories/user_repository.go @@ -1,7 +1,9 @@ -package user_repositories +package users_repositories import ( - user_models "postgresus-backend/internal/features/users/models" + "fmt" + users_enums "postgresus-backend/internal/features/users/enums" + users_models "postgresus-backend/internal/features/users/models" "postgresus-backend/internal/storage" "time" @@ -11,35 +13,35 @@ import ( type UserRepository struct{} -func (r *UserRepository) IsAnyUserExist() (bool, error) { - var user user_models.User - - if err := storage.GetDb().First(&user).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return false, nil - } - - return false, err +func (r *UserRepository) GetUsersCount() (int64, error) { + var count int64 + if err := storage.GetDb().Model(&users_models.User{}).Count(&count).Error; err != nil { + return 0, err } - return true, nil + return count, nil } -func (r *UserRepository) CreateUser(user *user_models.User) error { +func (r *UserRepository) CreateUser(user *users_models.User) error { return storage.GetDb().Create(user).Error } -func (r *UserRepository) GetUserByEmail(email string) (*user_models.User, error) { - var user user_models.User +func (r *UserRepository) GetUserByEmail(email string) (*users_models.User, error) { + var user users_models.User + if err := storage.GetDb().Where("email = ?", email).First(&user).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err } return &user, nil } -func (r *UserRepository) GetUserByID(userID string) (*user_models.User, error) { - var user user_models.User +func (r *UserRepository) GetUserByID(userID uuid.UUID) (*users_models.User, error) { + var user users_models.User if err := storage.GetDb().Where("id = ?", userID).First(&user).Error; err != nil { return nil, err @@ -48,21 +50,158 @@ func (r *UserRepository) GetUserByID(userID string) (*user_models.User, error) { return &user, nil } -func (r *UserRepository) GetFirstUser() (*user_models.User, error) { - var user user_models.User - - if err := storage.GetDb().First(&user).Error; err != nil { - return nil, err - } - - return &user, nil -} - func (r *UserRepository) UpdateUserPassword(userID uuid.UUID, hashedPassword string) error { - return storage.GetDb().Model(&user_models.User{}). + return storage.GetDb().Model(&users_models.User{}). Where("id = ?", userID). Updates(map[string]any{ "hashed_password": hashedPassword, "password_creation_time": time.Now().UTC(), }).Error } + +func (r *UserRepository) CreateInitialAdmin() error { + admin, err := r.GetUserByEmail("admin") + if err != nil { + return fmt.Errorf("failed to get admin user: %w", err) + } + + if admin != nil { + return nil + } + + admin = &users_models.User{ + ID: uuid.New(), + Email: "admin", + Name: "Admin", + HashedPassword: nil, + PasswordCreationTime: time.Now().UTC(), + Role: users_enums.UserRoleAdmin, + Status: users_enums.UserStatusActive, + CreatedAt: time.Now().UTC(), + } + + return storage.GetDb().Create(admin).Error +} + +func (r *UserRepository) GetUsers( + limit, offset int, + beforeCreatedAt *time.Time, + query string, +) ([]*users_models.User, int64, error) { + var users []*users_models.User + var total int64 + + countQuery := storage.GetDb().Model(&users_models.User{}) + if beforeCreatedAt != nil { + countQuery = countQuery.Where("created_at < ?", *beforeCreatedAt) + } + if query != "" { + searchPattern := "%" + query + "%" + countQuery = countQuery.Where("email ILIKE ? OR name ILIKE ?", searchPattern, searchPattern) + } + + if err := countQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + dataQuery := storage.GetDb(). + Limit(limit). + Offset(offset). + Order("created_at DESC") + + if beforeCreatedAt != nil { + dataQuery = dataQuery.Where("created_at < ?", *beforeCreatedAt) + } + if query != "" { + searchPattern := "%" + query + "%" + dataQuery = dataQuery.Where("email ILIKE ? OR name ILIKE ?", searchPattern, searchPattern) + } + + if err := dataQuery.Find(&users).Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} + +func (r *UserRepository) UpdateUserStatus(userID uuid.UUID, status users_enums.UserStatus) error { + return storage.GetDb().Model(&users_models.User{}). + Where("id = ?", userID). + Updates(map[string]any{ + "status": status, + }).Error +} + +func (r *UserRepository) UpdateUserRole(userID uuid.UUID, role users_enums.UserRole) error { + return storage.GetDb().Model(&users_models.User{}). + Where("id = ?", userID). + Updates(map[string]any{ + "role": role, + }).Error +} + +func (r *UserRepository) RenameUserEmailForTests(oldEmail, newEmail string) error { + result := storage.GetDb().Model(&users_models.User{}). + Where("email = ?", oldEmail). + Update("email", newEmail) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return nil + } + + return nil +} + +func (r *UserRepository) UpdateUserInfo(userID uuid.UUID, name *string, email *string) error { + updates := make(map[string]any) + + if name != nil { + updates["name"] = *name + } + if email != nil { + updates["email"] = *email + } + + if len(updates) == 0 { + return nil + } + + return storage.GetDb().Model(&users_models.User{}). + Where("id = ?", userID). + Updates(updates).Error +} + +func (r *UserRepository) GetUserByGitHubOAuthID(githubID string) (*users_models.User, error) { + var user users_models.User + err := storage.GetDb().Where("github_oauth_id = ?", githubID).First(&user).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) GetUserByGoogleOAuthID(googleID string) (*users_models.User, error) { + var user users_models.User + err := storage.GetDb().Where("google_oauth_id = ?", googleID).First(&user).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) LinkOAuthID(userID uuid.UUID, oauthColumn, oauthID string) error { + updates := map[string]any{oauthColumn: oauthID} + return storage.GetDb().Model(&users_models.User{}). + Where("id = ?", userID). + Updates(updates).Error +} diff --git a/backend/internal/features/users/repositories/users_settings_repository.go b/backend/internal/features/users/repositories/users_settings_repository.go new file mode 100644 index 0000000..dbdb3bc --- /dev/null +++ b/backend/internal/features/users/repositories/users_settings_repository.go @@ -0,0 +1,47 @@ +package users_repositories + +import ( + user_models "postgresus-backend/internal/features/users/models" + "postgresus-backend/internal/storage" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UsersSettingsRepository struct{} + +func (r *UsersSettingsRepository) GetSettings() (*user_models.UsersSettings, error) { + var settings user_models.UsersSettings + + if err := storage.GetDb().First(&settings).Error; err != nil { + if err == gorm.ErrRecordNotFound { + // Create default settings if none exist + defaultSettings := &user_models.UsersSettings{ + ID: uuid.New(), + IsAllowExternalRegistrations: true, + IsAllowMemberInvitations: true, + IsMemberAllowedToCreateWorkspaces: true, + } + + if createErr := storage.GetDb().Create(defaultSettings).Error; createErr != nil { + return nil, createErr + } + + return defaultSettings, nil + } + return nil, err + } + + return &settings, nil +} + +func (r *UsersSettingsRepository) UpdateSettings(settings *user_models.UsersSettings) error { + existingSettings, err := r.GetSettings() + if err != nil { + return err + } + + settings.ID = existingSettings.ID + + return storage.GetDb().Save(settings).Error +} diff --git a/backend/internal/features/users/service.go b/backend/internal/features/users/service.go deleted file mode 100644 index 85fbbd6..0000000 --- a/backend/internal/features/users/service.go +++ /dev/null @@ -1,175 +0,0 @@ -package users - -import ( - "errors" - "fmt" - "time" - - "github.com/golang-jwt/jwt/v4" - "github.com/google/uuid" - "golang.org/x/crypto/bcrypt" - - user_enums "postgresus-backend/internal/features/users/enums" - user_models "postgresus-backend/internal/features/users/models" - user_repositories "postgresus-backend/internal/features/users/repositories" -) - -type UserService struct { - userRepository *user_repositories.UserRepository - secretKeyRepository *user_repositories.SecretKeyRepository -} - -func (s *UserService) IsAnyUserExist() (bool, error) { - return s.userRepository.IsAnyUserExist() -} - -func (s *UserService) SignUp(request *SignUpRequest) error { - isAnyUserExists, err := s.userRepository.IsAnyUserExist() - if err != nil { - return fmt.Errorf("failed to check if any user exists: %w", err) - } - - if isAnyUserExists { - return errors.New("admin user already registered") - } - - existingUser, err := s.userRepository.GetUserByEmail(request.Email) - if err == nil && existingUser != nil { - return errors.New("user with this email already exists") - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - user := &user_models.User{ - ID: uuid.New(), - Email: request.Email, - HashedPassword: string(hashedPassword), - PasswordCreationTime: time.Now().UTC(), - CreatedAt: time.Now().UTC(), - Role: user_enums.UserRoleAdmin, - } - - if err := s.userRepository.CreateUser(user); err != nil { - return fmt.Errorf("failed to create user: %w", err) - } - - return nil -} - -func (s *UserService) SignIn(request *SignInRequest) (*SignInResponse, error) { - user, err := s.userRepository.GetUserByEmail(request.Email) - if err != nil { - return nil, errors.New("user with this email does not exist") - } - - err = bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(request.Password)) - if err != nil { - return nil, errors.New("password is incorrect") - } - - return s.GenerateAccessToken(user) -} - -func (s *UserService) GetUserFromToken(token string) (*user_models.User, error) { - secretKey, err := s.secretKeyRepository.GetSecretKey() - if err != nil { - return nil, fmt.Errorf("failed to get secret key: %w", err) - } - - parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(secretKey), nil - }) - - if err != nil { - return nil, fmt.Errorf("invalid token: %w", err) - } - - if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok && parsedToken.Valid { - userID, ok := claims["sub"].(string) - if !ok { - return nil, errors.New("invalid token claims") - } - - user, err := s.userRepository.GetUserByID(userID) - if err != nil { - return nil, err - } - - if passwordCreationTimeUnix, ok := claims["passwordCreationTime"].(float64); ok { - tokenPasswordTime := time.Unix(int64(passwordCreationTimeUnix), 0) - - tokenTimeSeconds := tokenPasswordTime.Truncate(time.Second) - userTimeSeconds := user.PasswordCreationTime.Truncate(time.Second) - - if !tokenTimeSeconds.Equal(userTimeSeconds) { - return nil, errors.New("password has been changed, please sign in again") - } - } else { - return nil, errors.New("invalid token claims: missing password creation time") - } - - return user, nil - } - - return nil, errors.New("invalid token") -} - -func (s *UserService) ChangePassword(newPassword string) error { - exists, err := s.userRepository.IsAnyUserExist() - if err != nil || !exists { - return errors.New("no users exist to change password") - } - - user, err := s.userRepository.GetFirstUser() - if err != nil { - return fmt.Errorf("failed to get user: %w", err) - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - if err := s.userRepository.UpdateUserPassword(user.ID, string(hashedPassword)); err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - return nil -} - -func (s *UserService) GetFirstUser() (*user_models.User, error) { - return s.userRepository.GetFirstUser() -} - -func (s *UserService) GenerateAccessToken(user *user_models.User) (*SignInResponse, error) { - secretKey, err := s.secretKeyRepository.GetSecretKey() - if err != nil { - return nil, fmt.Errorf("failed to get secret key: %w", err) - } - - tenYearsExpiration := time.Now().UTC().Add(time.Hour * 24 * 365 * 10) - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "sub": user.ID, - "exp": tenYearsExpiration.Unix(), - "iat": time.Now().UTC().Unix(), - "role": string(user.Role), - "passwordCreationTime": user.PasswordCreationTime.Unix(), - }) - - tokenString, err := token.SignedString([]byte(secretKey)) - if err != nil { - return nil, fmt.Errorf("failed to generate token: %w", err) - } - - return &SignInResponse{ - UserID: user.ID, - Token: tokenString, - }, nil -} diff --git a/backend/internal/features/users/services/di.go b/backend/internal/features/users/services/di.go new file mode 100644 index 0000000..fe2a478 --- /dev/null +++ b/backend/internal/features/users/services/di.go @@ -0,0 +1,36 @@ +package users_services + +import ( + user_repositories "postgresus-backend/internal/features/users/repositories" +) + +var secretKeyRepository = &user_repositories.SecretKeyRepository{} +var userRepository = &user_repositories.UserRepository{} +var usersSettingsRepository = &user_repositories.UsersSettingsRepository{} + +var userService = &UserService{ + userRepository, + secretKeyRepository, + settingsService, + nil, +} +var settingsService = &SettingsService{ + usersSettingsRepository, + nil, +} +var managementService = &UserManagementService{ + userRepository, + nil, +} + +func GetUserService() *UserService { + return userService +} + +func GetSettingsService() *SettingsService { + return settingsService +} + +func GetManagementService() *UserManagementService { + return managementService +} diff --git a/backend/internal/features/users/services/management_service.go b/backend/internal/features/users/services/management_service.go new file mode 100644 index 0000000..4c17424 --- /dev/null +++ b/backend/internal/features/users/services/management_service.go @@ -0,0 +1,166 @@ +package users_services + +import ( + "errors" + "fmt" + "time" + + user_enums "postgresus-backend/internal/features/users/enums" + user_interfaces "postgresus-backend/internal/features/users/interfaces" + user_models "postgresus-backend/internal/features/users/models" + user_repositories "postgresus-backend/internal/features/users/repositories" + + "github.com/google/uuid" +) + +type UserManagementService struct { + userRepository *user_repositories.UserRepository + auditLogWriter user_interfaces.AuditLogWriter +} + +func (s *UserManagementService) SetAuditLogWriter(writer user_interfaces.AuditLogWriter) { + s.auditLogWriter = writer +} + +func (s *UserManagementService) GetUsers( + currentUser *user_models.User, + limit, offset int, + beforeCreatedAt *time.Time, + query string, +) ([]*user_models.User, int64, error) { + if !currentUser.CanManageUsers() { + return nil, 0, errors.New("insufficient permissions to list users") + } + + return s.userRepository.GetUsers(limit, offset, beforeCreatedAt, query) +} + +func (s *UserManagementService) GetUserProfile( + userID uuid.UUID, + requestedBy *user_models.User, +) (*user_models.User, error) { + // Users can view their own profile, admins can view any profile + if userID != requestedBy.ID && !requestedBy.CanManageUsers() { + return nil, errors.New("insufficient permissions to view user profile") + } + + return s.userRepository.GetUserByID(userID) +} + +func (s *UserManagementService) DeactivateUser( + userID uuid.UUID, + deactivatedBy *user_models.User, +) error { + if !deactivatedBy.CanManageUsers() { + return errors.New("insufficient permissions to deactivate users") + } + + // Don't allow deactivating self + if userID == deactivatedBy.ID { + return errors.New("cannot deactivate your own account") + } + + user, err := s.userRepository.GetUserByID(userID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + // Only user with email "admin" can deactivate ADMIN users + if user.Role == user_enums.UserRoleAdmin && deactivatedBy.Email != "admin" { + return errors.New("only the root admin user can deactivate admin accounts") + } + + if err := s.userRepository.UpdateUserStatus(userID, user_enums.UserStatusInactive); err != nil { + return fmt.Errorf("failed to deactivate user: %w", err) + } + + if s.auditLogWriter != nil { + s.auditLogWriter.WriteAuditLog( + fmt.Sprintf("User deactivated: %s", user.Email), + &deactivatedBy.ID, + nil, + ) + } + + return nil +} + +func (s *UserManagementService) ActivateUser( + userID uuid.UUID, + activatedBy *user_models.User, +) error { + if !activatedBy.CanManageUsers() { + return errors.New("insufficient permissions to activate users") + } + + user, err := s.userRepository.GetUserByID(userID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + // Only user with email "admin" can activate ADMIN users + if user.Role == user_enums.UserRoleAdmin && activatedBy.Email != "admin" { + return errors.New("only the root admin user can activate admin accounts") + } + + if err := s.userRepository.UpdateUserStatus(userID, user_enums.UserStatusActive); err != nil { + return fmt.Errorf("failed to activate user: %w", err) + } + + if s.auditLogWriter != nil { + s.auditLogWriter.WriteAuditLog( + fmt.Sprintf("User activated: %s", user.Email), + &activatedBy.ID, + nil, + ) + } + + return nil +} + +func (s *UserManagementService) ChangeUserRole( + userID uuid.UUID, + newRole user_enums.UserRole, + changedBy *user_models.User, +) error { + if !changedBy.CanManageUsers() { + return errors.New("insufficient permissions to change user roles") + } + + // Validate role + if !newRole.IsValid() { + return errors.New("invalid user role") + } + + // Don't allow changing own role + if userID == changedBy.ID { + return errors.New("cannot change your own role") + } + + user, err := s.userRepository.GetUserByID(userID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + // Only user with email "admin" can promote users to ADMIN or demote ADMIN users + if (newRole == user_enums.UserRoleAdmin || user.Role == user_enums.UserRoleAdmin) && + changedBy.Email != "admin" { + return errors.New( + "only the root admin user can promote users to admin or demote admin users", + ) + } + + if err := s.userRepository.UpdateUserRole(userID, newRole); err != nil { + return fmt.Errorf("failed to update user role: %w", err) + } + + if s.auditLogWriter != nil { + s.auditLogWriter.WriteAuditLog( + fmt.Sprintf("User role changed: %s from %s to %s", user.Email, user.Role, newRole), + &changedBy.ID, + nil, + ) + } + + return nil +} diff --git a/backend/internal/features/users/services/oauth_testing.go b/backend/internal/features/users/services/oauth_testing.go new file mode 100644 index 0000000..ef83169 --- /dev/null +++ b/backend/internal/features/users/services/oauth_testing.go @@ -0,0 +1,23 @@ +package users_services + +import ( + users_dto "postgresus-backend/internal/features/users/dto" + + "golang.org/x/oauth2" +) + +func (s *UserService) HandleGitHubOAuthWithMockEndpoint( + code, redirectUri string, + endpoint oauth2.Endpoint, + userAPIURL string, +) (*users_dto.OAuthCallbackResponseDTO, error) { + return s.handleGitHubOAuthWithEndpoint(code, redirectUri, endpoint, userAPIURL) +} + +func (s *UserService) HandleGoogleOAuthWithMockEndpoint( + code, redirectUri string, + endpoint oauth2.Endpoint, + userAPIURL string, +) (*users_dto.OAuthCallbackResponseDTO, error) { + return s.handleGoogleOAuthWithEndpoint(code, redirectUri, endpoint, userAPIURL) +} diff --git a/backend/internal/features/users/services/settings_service.go b/backend/internal/features/users/services/settings_service.go new file mode 100644 index 0000000..b720725 --- /dev/null +++ b/backend/internal/features/users/services/settings_service.go @@ -0,0 +1,80 @@ +package users_services + +import ( + "fmt" + + users_interfaces "postgresus-backend/internal/features/users/interfaces" + users_models "postgresus-backend/internal/features/users/models" + users_repositories "postgresus-backend/internal/features/users/repositories" +) + +type SettingsService struct { + userSettingsRepository *users_repositories.UsersSettingsRepository + auditLogWriter users_interfaces.AuditLogWriter +} + +func (s *SettingsService) SetAuditLogWriter(writer users_interfaces.AuditLogWriter) { + s.auditLogWriter = writer +} + +func (s *SettingsService) GetSettings() (*users_models.UsersSettings, error) { + return s.userSettingsRepository.GetSettings() +} + +func (s *SettingsService) UpdateSettings( + request users_models.UsersSettings, + updatedBy *users_models.User, +) (*users_models.UsersSettings, error) { + if !updatedBy.CanUpdateSettings() { + return nil, fmt.Errorf("insufficient permissions to update settings") + } + + existingSettings, err := s.userSettingsRepository.GetSettings() + if err != nil { + return nil, fmt.Errorf("failed to get current settings: %w", err) + } + + auditLogMessages := []string{} + + if request.IsAllowExternalRegistrations != existingSettings.IsAllowExternalRegistrations { + existingSettings.IsAllowExternalRegistrations = request.IsAllowExternalRegistrations + auditLogMessages = append( + auditLogMessages, + fmt.Sprintf( + "isAllowExternalRegistrations: %t -> %t", + existingSettings.IsAllowExternalRegistrations, + request.IsAllowExternalRegistrations, + ), + ) + } + + if request.IsAllowMemberInvitations != existingSettings.IsAllowMemberInvitations { + existingSettings.IsAllowMemberInvitations = request.IsAllowMemberInvitations + auditLogMessages = append( + auditLogMessages, + fmt.Sprintf( + "isAllowMemberInvitations: %t -> %t", + existingSettings.IsAllowMemberInvitations, + request.IsAllowMemberInvitations, + ), + ) + } + + if request.IsMemberAllowedToCreateWorkspaces != existingSettings.IsMemberAllowedToCreateWorkspaces { + existingSettings.IsMemberAllowedToCreateWorkspaces = request.IsMemberAllowedToCreateWorkspaces + } + + if err := s.userSettingsRepository.UpdateSettings(existingSettings); err != nil { + return nil, fmt.Errorf("failed to update settings: %w", err) + } + + for _, message := range auditLogMessages { + s.auditLogWriter.WriteAuditLog( + message, + &updatedBy.ID, + nil, + ) + } + + return existingSettings, nil +} diff --git a/backend/internal/features/users/services/user_services.go b/backend/internal/features/users/services/user_services.go new file mode 100644 index 0000000..05825d9 --- /dev/null +++ b/backend/internal/features/users/services/user_services.go @@ -0,0 +1,798 @@ +package users_services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + "golang.org/x/oauth2/google" + + "postgresus-backend/internal/config" + users_dto "postgresus-backend/internal/features/users/dto" + users_enums "postgresus-backend/internal/features/users/enums" + users_interfaces "postgresus-backend/internal/features/users/interfaces" + users_models "postgresus-backend/internal/features/users/models" + users_repositories "postgresus-backend/internal/features/users/repositories" +) + +type UserService struct { + userRepository *users_repositories.UserRepository + secretKeyRepository *users_repositories.SecretKeyRepository + settingsService *SettingsService + auditLogWriter users_interfaces.AuditLogWriter +} + +func (s *UserService) SetAuditLogWriter(writer users_interfaces.AuditLogWriter) { + s.auditLogWriter = writer +} + +func (s *UserService) SignUp(request *users_dto.SignUpRequestDTO) error { + existingUser, err := s.userRepository.GetUserByEmail(request.Email) + if err != nil { + return fmt.Errorf("failed to check existing user: %w", err) + } + + if existingUser != nil && existingUser.Status != users_enums.UserStatusInvited { + return errors.New("user with this email already exists") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + hashedPasswordStr := string(hashedPassword) + + // If user exists with INVITED status, activate them and set password + if existingUser != nil && existingUser.Status == users_enums.UserStatusInvited { + if err := s.userRepository.UpdateUserPassword(existingUser.ID, hashedPasswordStr); err != nil { + return fmt.Errorf("failed to set password: %w", err) + } + + if err := s.userRepository.UpdateUserStatus(existingUser.ID, users_enums.UserStatusActive); err != nil { + return fmt.Errorf("failed to activate user: %w", err) + } + + name := request.Name + if err := s.userRepository.UpdateUserInfo(existingUser.ID, &name, nil); err != nil { + return fmt.Errorf("failed to update name: %w", err) + } + + s.auditLogWriter.WriteAuditLog( + fmt.Sprintf("Invited user completed registration: %s", existingUser.Email), + &existingUser.ID, + nil, + ) + + return nil + } + + // Get settings to check registration policy for new users + settings, err := s.settingsService.GetSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + + // Check if external registrations are allowed + if !settings.IsAllowExternalRegistrations { + return errors.New("external registration is disabled") + } + + user := &users_models.User{ + ID: uuid.New(), + Email: request.Email, + Name: request.Name, + HashedPassword: &hashedPasswordStr, + PasswordCreationTime: time.Now().UTC(), + Role: users_enums.UserRoleMember, + Status: users_enums.UserStatusActive, + CreatedAt: time.Now().UTC(), + } + + if err := s.userRepository.CreateUser(user); err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + s.auditLogWriter.WriteAuditLog( + fmt.Sprintf("User registered with email: %s", user.Email), + &user.ID, + nil, + ) + + return nil +} + +func (s *UserService) SignIn( + request *users_dto.SignInRequestDTO, +) (*users_dto.SignInResponseDTO, error) { + user, err := s.userRepository.GetUserByEmail(request.Email) + if err != nil { + return nil, errors.New("user with this email does not exist") + } + + if user == nil { + usersCount, err := s.userRepository.GetUsersCount() + if err != nil { + return nil, fmt.Errorf("failed to get users count: %w", err) + } + + if usersCount == 1 { + return nil, errors.New( + "user with this email does not exist, seems you need to sign in as \"admin\"", + ) + } + + return nil, errors.New("user with this email does not exist") + } + + if user.Status == users_enums.UserStatusInvited { + return nil, errors.New("user account is not passed sign up yet") + } + + if user.Status != users_enums.UserStatusActive { + return nil, errors.New("user account is deactivated") + } + + err = bcrypt.CompareHashAndPassword([]byte(*user.HashedPassword), []byte(request.Password)) + if err != nil { + return nil, errors.New("password is incorrect") + } + + response, err := s.GenerateAccessToken(user) + if err != nil { + return nil, err + } + + s.auditLogWriter.WriteAuditLog( + fmt.Sprintf("User signed in with email: %s", user.Email), + &user.ID, + nil, + ) + + return response, nil +} + +func (s *UserService) GetUserFromToken(token string) (*users_models.User, error) { + secretKey, err := s.secretKeyRepository.GetSecretKey() + if err != nil { + return nil, fmt.Errorf("failed to get secret key: %w", err) + } + + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secretKey), nil + }) + + if err != nil { + return nil, fmt.Errorf("invalid token: %w", err) + } + + if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok && parsedToken.Valid { + userIDStr, ok := claims["sub"].(string) + if !ok { + return nil, errors.New("invalid token claims") + } + + userID, err := uuid.Parse(userIDStr) + if err != nil { + return nil, errors.New("invalid token claims") + } + + user, err := s.userRepository.GetUserByID(userID) + if err != nil { + return nil, err + } + + // Check if user is active + if !user.IsActiveUser() { + return nil, errors.New("user account is deactivated") + } + + if passwordCreationTimeUnix, ok := claims["passwordCreationTime"].(float64); ok { + tokenPasswordTime := time.Unix(int64(passwordCreationTimeUnix), 0) + + tokenTimeSeconds := tokenPasswordTime.Truncate(time.Second) + userTimeSeconds := user.PasswordCreationTime.Truncate(time.Second) + + if !tokenTimeSeconds.Equal(userTimeSeconds) { + return nil, errors.New("password has been changed, please sign in again") + } + } else { + return nil, errors.New("invalid token claims: missing password creation time") + } + + return user, nil + } + + return nil, errors.New("invalid token") +} + +func (s *UserService) GenerateAccessToken( + user *users_models.User, +) (*users_dto.SignInResponseDTO, error) { + secretKey, err := s.secretKeyRepository.GetSecretKey() + if err != nil { + return nil, fmt.Errorf("failed to get secret key: %w", err) + } + + tenYearsExpiration := time.Now().UTC().Add(time.Hour * 24 * 365 * 10) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": user.ID.String(), + "exp": tenYearsExpiration.Unix(), + "iat": time.Now().UTC().Unix(), + "role": string(user.Role), + "passwordCreationTime": user.PasswordCreationTime.Unix(), + }) + + tokenString, err := token.SignedString([]byte(secretKey)) + if err != nil { + return nil, fmt.Errorf("failed to generate token: %w", err) + } + + return &users_dto.SignInResponseDTO{ + UserID: user.ID, + Token: tokenString, + }, nil +} + +func (s *UserService) CreateInitialAdmin() error { + return s.userRepository.CreateInitialAdmin() +} + +func (s *UserService) IsRootAdminHasPassword() (bool, error) { + admin, err := s.userRepository.GetUserByEmail("admin") + + if err != nil { + return false, fmt.Errorf("failed to get admin user: %w", err) + } + + if admin == nil { + return false, errors.New("admin user does not exist") + } + + return admin.HasPassword(), nil +} + +func (s *UserService) SetRootAdminPassword(password string) error { + admin, err := s.userRepository.GetUserByEmail("admin") + if err != nil { + return fmt.Errorf("failed to get admin user: %w", err) + } + + if admin == nil { + return errors.New("admin user does not exist") + } + + if admin.HasPassword() { + return errors.New("admin password is already set") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + if err := s.userRepository.UpdateUserPassword(admin.ID, string(hashedPassword)); err != nil { + return fmt.Errorf("failed to set admin password: %w", err) + } + + if s.auditLogWriter != nil { + s.auditLogWriter.WriteAuditLog( + "Admin password set", + &admin.ID, + nil, + ) + } + + return nil +} + +func (s *UserService) ChangeUserPasswordByEmail(email string, newPassword string) error { + user, err := s.userRepository.GetUserByEmail(email) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + return s.ChangeUserPassword(user.ID, newPassword) +} + +func (s *UserService) ChangeUserPassword(userID uuid.UUID, newPassword string) error { + user, err := s.userRepository.GetUserByID(userID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + if !user.HasPassword() { + return errors.New("user has no password set") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash new password: %w", err) + } + + if err := s.userRepository.UpdateUserPassword(userID, string(hashedPassword)); err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + s.auditLogWriter.WriteAuditLog( + "Password changed", + &userID, + nil, + ) + + return nil +} + +func (s *UserService) InviteUser( + request *users_dto.InviteUserRequestDTO, + invitedBy *users_models.User, +) (*users_dto.InviteUserResponseDTO, error) { + // Get settings to check permissions + settings, err := s.settingsService.GetSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + + // Check if user has permission to invite + if !invitedBy.CanInviteUsers(settings) { + return nil, errors.New("insufficient permissions to invite users") + } + + // Check if user already exists + existingUser, err := s.userRepository.GetUserByEmail(request.Email) + if err != nil { + return nil, fmt.Errorf("failed to check existing user: %w", err) + } + if existingUser != nil { + return nil, errors.New("user with this email already exists") + } + + user := &users_models.User{ + ID: uuid.New(), + Email: request.Email, + Name: "User", + HashedPassword: nil, + PasswordCreationTime: time.Now().UTC(), + Role: users_enums.UserRoleMember, + Status: users_enums.UserStatusInvited, + CreatedAt: time.Now().UTC(), + } + + if err := s.userRepository.CreateUser(user); err != nil { + return nil, fmt.Errorf("failed to create invited user: %w", err) + } + + message := fmt.Sprintf("User invited: %s", request.Email) + if request.IntendedWorkspaceID != nil { + message += fmt.Sprintf(" for workspace %s", request.IntendedWorkspaceID.String()) + } + s.auditLogWriter.WriteAuditLog( + message, + &invitedBy.ID, + request.IntendedWorkspaceID, + ) + + return &users_dto.InviteUserResponseDTO{ + ID: user.ID, + Email: user.Email, + IntendedWorkspaceID: request.IntendedWorkspaceID, + IntendedWorkspaceRole: request.IntendedWorkspaceRole, + CreatedAt: user.CreatedAt, + }, nil +} + +func (s *UserService) GetUserByID(userID uuid.UUID) (*users_models.User, error) { + return s.userRepository.GetUserByID(userID) +} + +func (s *UserService) GetUserByEmail(email string) (*users_models.User, error) { + return s.userRepository.GetUserByEmail(email) +} + +func (s *UserService) GetCurrentUserProfile( + user *users_models.User, +) *users_dto.UserProfileResponseDTO { + return &users_dto.UserProfileResponseDTO{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Role: user.Role, + IsActive: user.IsActiveUser(), + CreatedAt: user.CreatedAt, + } +} + +func (s *UserService) UpdateUserInfo( + userID uuid.UUID, + request *users_dto.UpdateUserInfoRequestDTO, +) error { + user, err := s.userRepository.GetUserByID(userID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + if user.Email == "admin" && request.Email != nil && *request.Email != user.Email { + return errors.New("admin email cannot be changed") + } + + if request.Email != nil && *request.Email != user.Email { + existingUser, err := s.userRepository.GetUserByEmail(*request.Email) + if err != nil { + return fmt.Errorf("failed to check email: %w", err) + } + if existingUser != nil { + return errors.New("email is already taken by another user") + } + } + + if err := s.userRepository.UpdateUserInfo(userID, request.Name, request.Email); err != nil { + return fmt.Errorf("failed to update user info: %w", err) + } + + s.auditLogWriter.WriteAuditLog("User info updated", &userID, nil) + return nil +} + +func (s *UserService) HandleGitHubOAuth( + code, redirectUri string, +) (*users_dto.OAuthCallbackResponseDTO, error) { + return s.handleGitHubOAuthWithEndpoint( + code, + redirectUri, + github.Endpoint, + "https://api.github.com/user", + ) +} + +func (s *UserService) handleGitHubOAuthWithEndpoint( + code, redirectUri string, + endpoint oauth2.Endpoint, + userAPIURL string, +) (*users_dto.OAuthCallbackResponseDTO, error) { + env := config.GetEnv() + + oauthConfig := &oauth2.Config{ + ClientID: env.GitHubClientID, + ClientSecret: env.GitHubClientSecret, + RedirectURL: redirectUri, + Endpoint: endpoint, + Scopes: []string{"user:email"}, + } + + token, err := oauthConfig.Exchange(context.Background(), code) + if err != nil { + return nil, fmt.Errorf("failed to exchange code: %w", err) + } + + client := oauthConfig.Client(context.Background(), token) + resp, err := client.Get(userAPIURL) + if err != nil { + return nil, fmt.Errorf("failed to get user info: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var githubUser struct { + ID int64 `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Login string `json:"login"` + } + + if err := json.Unmarshal(body, &githubUser); err != nil { + return nil, fmt.Errorf("failed to parse user info: %w", err) + } + + email := githubUser.Email + if email == "" { + email, err = s.fetchGitHubPrimaryEmail(client, userAPIURL) + if err != nil { + return nil, err + } + } + + name := githubUser.Name + if name == "" { + name = githubUser.Login + } + + oauthID := fmt.Sprintf("%d", githubUser.ID) + return s.getOrCreateUserFromOAuth(oauthID, email, name, "github") +} + +func (s *UserService) HandleGoogleOAuth( + code, redirectUri string, +) (*users_dto.OAuthCallbackResponseDTO, error) { + return s.handleGoogleOAuthWithEndpoint( + code, + redirectUri, + google.Endpoint, + "https://www.googleapis.com/oauth2/v2/userinfo", + ) +} + +func (s *UserService) handleGoogleOAuthWithEndpoint( + code, redirectUri string, + endpoint oauth2.Endpoint, + userAPIURL string, +) (*users_dto.OAuthCallbackResponseDTO, error) { + env := config.GetEnv() + + oauthConfig := &oauth2.Config{ + ClientID: env.GoogleClientID, + ClientSecret: env.GoogleClientSecret, + RedirectURL: redirectUri, + Endpoint: endpoint, + Scopes: []string{ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + }, + } + + token, err := oauthConfig.Exchange(context.Background(), code) + if err != nil { + return nil, fmt.Errorf("failed to exchange code: %w", err) + } + + client := oauthConfig.Client(context.Background(), token) + resp, err := client.Get(userAPIURL) + if err != nil { + return nil, fmt.Errorf("failed to get user info: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("google API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var googleUser struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + } + + if err := json.Unmarshal(body, &googleUser); err != nil { + return nil, fmt.Errorf("failed to parse user info: %w", err) + } + + if googleUser.Email == "" { + return nil, errors.New("google account has no email") + } + + name := googleUser.Name + if name == "" { + name = "User" + } + + return s.getOrCreateUserFromOAuth(googleUser.ID, googleUser.Email, name, "google") +} + +func (s *UserService) getOrCreateUserFromOAuth( + oauthID, email, name, provider string, +) (*users_dto.OAuthCallbackResponseDTO, error) { + var existingUser *users_models.User + var err error + + if provider == "github" { + existingUser, err = s.userRepository.GetUserByGitHubOAuthID(oauthID) + } else { + existingUser, err = s.userRepository.GetUserByGoogleOAuthID(oauthID) + } + + if err != nil { + return nil, fmt.Errorf("failed to check OAuth ID: %w", err) + } + + if existingUser != nil { + tokenResponse, err := s.GenerateAccessToken(existingUser) + if err != nil { + return nil, err + } + + if s.auditLogWriter != nil { + s.auditLogWriter.WriteAuditLog( + fmt.Sprintf("User signed in via %s", provider), + &existingUser.ID, + nil, + ) + } + + return &users_dto.OAuthCallbackResponseDTO{ + UserID: tokenResponse.UserID, + Email: existingUser.Email, + Token: tokenResponse.Token, + IsNewUser: false, + }, nil + } + + userByEmail, err := s.userRepository.GetUserByEmail(email) + if err != nil { + return nil, fmt.Errorf("failed to check email: %w", err) + } + + if userByEmail != nil { + if userByEmail.Status == users_enums.UserStatusInvited { + if err := s.userRepository.UpdateUserStatus(userByEmail.ID, users_enums.UserStatusActive); err != nil { + return nil, fmt.Errorf("failed to activate user: %w", err) + } + + if err := s.userRepository.UpdateUserInfo(userByEmail.ID, &name, nil); err != nil { + return nil, fmt.Errorf("failed to update name: %w", err) + } + } + + oauthColumn := "github_oauth_id" + if provider == "google" { + oauthColumn = "google_oauth_id" + } + + if err := s.userRepository.LinkOAuthID(userByEmail.ID, oauthColumn, oauthID); err != nil { + return nil, fmt.Errorf("failed to link OAuth ID: %w", err) + } + + user, err := s.userRepository.GetUserByID(userByEmail.ID) + if err != nil { + return nil, fmt.Errorf("failed to get updated user: %w", err) + } + + tokenResponse, err := s.GenerateAccessToken(user) + if err != nil { + return nil, err + } + + if s.auditLogWriter != nil { + s.auditLogWriter.WriteAuditLog( + fmt.Sprintf("%s OAuth linked to existing account", provider), + &user.ID, + nil, + ) + } + + return &users_dto.OAuthCallbackResponseDTO{ + UserID: tokenResponse.UserID, + Email: user.Email, + Token: tokenResponse.Token, + IsNewUser: false, + }, nil + } + + settings, err := s.settingsService.GetSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + + if !settings.IsAllowExternalRegistrations { + return nil, errors.New("external registration is disabled") + } + + var githubOAuthID *string + var googleOAuthID *string + if provider == "github" { + githubOAuthID = &oauthID + } else { + googleOAuthID = &oauthID + } + + newUser := &users_models.User{ + ID: uuid.New(), + Email: email, + Name: name, + HashedPassword: nil, + PasswordCreationTime: time.Now().UTC(), + Role: users_enums.UserRoleMember, + Status: users_enums.UserStatusActive, + GitHubOAuthID: githubOAuthID, + GoogleOAuthID: googleOAuthID, + CreatedAt: time.Now().UTC(), + } + + if err := s.userRepository.CreateUser(newUser); err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + tokenResponse, err := s.GenerateAccessToken(newUser) + if err != nil { + return nil, err + } + + if s.auditLogWriter != nil { + s.auditLogWriter.WriteAuditLog( + fmt.Sprintf("User registered via %s OAuth: %s", provider, email), + &newUser.ID, + nil, + ) + } + + return &users_dto.OAuthCallbackResponseDTO{ + UserID: tokenResponse.UserID, + Email: newUser.Email, + Token: tokenResponse.Token, + IsNewUser: true, + }, nil +} + +func (s *UserService) fetchGitHubPrimaryEmail( + client *http.Client, + userAPIURL string, +) (string, error) { + emailsURL := "https://api.github.com/user/emails" + if userAPIURL != "https://api.github.com/user" { + baseURL := userAPIURL[:len(userAPIURL)-len("/user")] + emailsURL = baseURL + "/user/emails" + } + + resp, err := client.Get(emailsURL) + if err != nil { + return "", fmt.Errorf("failed to get user emails: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return "", errors.New("github account has no accessible email") + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read emails response: %w", err) + } + + var emails []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + + if err := json.Unmarshal(body, &emails); err != nil { + return "", fmt.Errorf("failed to parse emails: %w", err) + } + + for _, email := range emails { + if email.Primary && email.Verified { + return email.Email, nil + } + } + + for _, email := range emails { + if email.Verified { + return email.Email, nil + } + } + + if len(emails) > 0 { + return emails[0].Email, nil + } + + return "", errors.New("github account has no accessible email") +} diff --git a/backend/internal/features/users/testing.go b/backend/internal/features/users/testing.go deleted file mode 100644 index 5e8bb11..0000000 --- a/backend/internal/features/users/testing.go +++ /dev/null @@ -1,31 +0,0 @@ -package users - -func GetTestUser() *SignInResponse { - isAnyUserExists, err := userService.IsAnyUserExist() - if err != nil { - panic(err) - } - - if !isAnyUserExists { - err = userService.SignUp(&SignUpRequest{ - Email: "test@test.com", - Password: "test", - }) - - if err != nil { - panic(err) - } - } - - user, err := userService.GetFirstUser() - if err != nil { - panic(err) - } - - signInResponse, err := userService.GenerateAccessToken(user) - if err != nil { - panic(err) - } - - return signInResponse -} diff --git a/backend/internal/features/users/testing/settings_utils.go b/backend/internal/features/users/testing/settings_utils.go new file mode 100644 index 0000000..cabbc4e --- /dev/null +++ b/backend/internal/features/users/testing/settings_utils.go @@ -0,0 +1,68 @@ +package users_testing + +import ( + users_repositories "postgresus-backend/internal/features/users/repositories" +) + +func EnableMemberInvitations() { + updateUsersSetting("is_allow_member_invitations", true) +} + +func DisableMemberInvitations() { + updateUsersSetting("is_allow_member_invitations", false) +} + +func EnableExternalRegistrations() { + updateUsersSetting("is_allow_external_registrations", true) +} + +func DisableExternalRegistrations() { + updateUsersSetting("is_allow_external_registrations", false) +} + +func EnableMemberWorkspaceCreation() { + updateUsersSetting("is_member_allowed_to_create_workspaces", true) +} + +func DisableMemberWorkspaceCreation() { + updateUsersSetting("is_member_allowed_to_create_workspaces", false) +} + +func ResetSettingsToDefaults() { + repository := &users_repositories.UsersSettingsRepository{} + settings, err := repository.GetSettings() + if err != nil { + panic(err) + } + + settings.IsAllowExternalRegistrations = true + settings.IsAllowMemberInvitations = true + settings.IsMemberAllowedToCreateWorkspaces = true + + err = repository.UpdateSettings(settings) + if err != nil { + panic(err) + } +} + +func updateUsersSetting(column string, value bool) { + repository := &users_repositories.UsersSettingsRepository{} + settings, err := repository.GetSettings() + if err != nil { + panic(err) + } + + switch column { + case "is_allow_member_invitations": + settings.IsAllowMemberInvitations = value + case "is_allow_external_registrations": + settings.IsAllowExternalRegistrations = value + case "is_member_allowed_to_create_workspaces": + settings.IsMemberAllowedToCreateWorkspaces = value + } + + err = repository.UpdateSettings(settings) + if err != nil { + panic(err) + } +} diff --git a/backend/internal/features/users/testing/user_utils.go b/backend/internal/features/users/testing/user_utils.go new file mode 100644 index 0000000..44c2f3f --- /dev/null +++ b/backend/internal/features/users/testing/user_utils.go @@ -0,0 +1,78 @@ +package users_testing + +import ( + "fmt" + "strings" + "time" + + users_dto "postgresus-backend/internal/features/users/dto" + users_enums "postgresus-backend/internal/features/users/enums" + users_models "postgresus-backend/internal/features/users/models" + users_repositories "postgresus-backend/internal/features/users/repositories" + users_services "postgresus-backend/internal/features/users/services" + + "github.com/google/uuid" +) + +func CreateTestUser(role users_enums.UserRole) *users_dto.SignInResponseDTO { + userID := uuid.New() + email := fmt.Sprintf("%s-%s@test.com", strings.ToLower(string(role)), userID.String()[:8]) + + hashedPassword := "$2a$10$test" + user := &users_models.User{ + ID: userID, + Email: email, + Name: "Test User", + HashedPassword: &hashedPassword, + PasswordCreationTime: time.Now().UTC(), + CreatedAt: time.Now().UTC(), + Role: role, + Status: users_enums.UserStatusActive, + } + + userRepository := &users_repositories.UserRepository{} + err := userRepository.CreateUser(user) + if err != nil { + panic(err) + } + + response, err := users_services.GetUserService().GenerateAccessToken(user) + if err != nil { + panic(err) + } + + response.Email = user.Email + + return response +} + +func ReacreateInitAdminAndGetAccess() *users_dto.SignInResponseDTO { + RecreateInitialAdmin() + + userRepository := &users_repositories.UserRepository{} + user, err := userRepository.GetUserByEmail("admin") + if err != nil { + panic(err) + } + + response, err := users_services.GetUserService().GenerateAccessToken(user) + if err != nil { + panic(err) + } + + return response +} + +func RecreateInitialAdmin() { + userRepository := &users_repositories.UserRepository{} + err := userRepository.RenameUserEmailForTests("admin", "admin-"+uuid.New().String()) + if err != nil { + panic(err) + } + + userService := users_services.GetUserService() + err = userService.CreateInitialAdmin() + if err != nil { + panic(err) + } +} diff --git a/backend/internal/features/workspaces/controllers/di.go b/backend/internal/features/workspaces/controllers/di.go new file mode 100644 index 0000000..19e856e --- /dev/null +++ b/backend/internal/features/workspaces/controllers/di.go @@ -0,0 +1,21 @@ +package workspaces_controllers + +import ( + workspaces_services "postgresus-backend/internal/features/workspaces/services" +) + +var workspaceController = &WorkspaceController{ + workspaces_services.GetWorkspaceService(), +} + +var membershipController = &MembershipController{ + workspaces_services.GetMembershipService(), +} + +func GetWorkspaceController() *WorkspaceController { + return workspaceController +} + +func GetMembershipController() *MembershipController { + return membershipController +} diff --git a/backend/internal/features/workspaces/controllers/e2e_test.go b/backend/internal/features/workspaces/controllers/e2e_test.go new file mode 100644 index 0000000..d743f91 --- /dev/null +++ b/backend/internal/features/workspaces/controllers/e2e_test.go @@ -0,0 +1,279 @@ +package workspaces_controllers + +import ( + "net/http" + "testing" + + users_enums "postgresus-backend/internal/features/users/enums" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_dto "postgresus-backend/internal/features/workspaces/dto" + workspaces_models "postgresus-backend/internal/features/workspaces/models" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func Test_WorkspaceLifecycleE2E_CompletesSuccessfully(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + users_testing.EnableMemberWorkspaceCreation() + defer users_testing.ResetSettingsToDefaults() + + // 1. Create workspace owner + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + + // 2. Owner creates workspace + createRequest := workspaces_dto.CreateWorkspaceRequestDTO{ + Name: "E2E Test Workspace", + } + + var workspaceResponse workspaces_dto.WorkspaceResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces", + "Bearer "+owner.Token, + createRequest, + http.StatusOK, + &workspaceResponse, + ) + + assert.Equal(t, "E2E Test Workspace", workspaceResponse.Name) + assert.Equal(t, users_enums.WorkspaceRoleOwner, *workspaceResponse.UserRole) + workspaceID := workspaceResponse.ID + + // 3. Owner invites a new user + inviteRequest := workspaces_dto.AddMemberRequestDTO{ + Email: "invited" + uuid.New().String() + "@example.com", + Role: users_enums.WorkspaceRoleViewer, + } + + var inviteResponse workspaces_dto.AddMemberResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/memberships/"+workspaceID.String()+"/members", + "Bearer "+owner.Token, + inviteRequest, + http.StatusOK, + &inviteResponse, + ) + + assert.True(t, inviteResponse.Status == workspaces_dto.AddStatusInvited) + + // 4. Add existing user to workspace + existingMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + addMemberRequest := workspaces_dto.AddMemberRequestDTO{ + Email: existingMember.Email, + Role: users_enums.WorkspaceRoleViewer, + } + + var addMemberResponse workspaces_dto.AddMemberResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/memberships/"+workspaceID.String()+"/members", + "Bearer "+owner.Token, + addMemberRequest, + http.StatusOK, + &addMemberResponse, + ) + + assert.True(t, addMemberResponse.Status == workspaces_dto.AddStatusAdded) + + // 5. List workspace members + var membersResponse workspaces_dto.GetMembersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/memberships/"+workspaceID.String()+"/members", + "Bearer "+owner.Token, + http.StatusOK, + &membersResponse, + ) + + assert.GreaterOrEqual(t, len(membersResponse.Members), 2) // owner + added member + + roles := make([]users_enums.WorkspaceRole, len(membersResponse.Members)) + for i, m := range membersResponse.Members { + roles[i] = m.Role + } + assert.Contains(t, roles, users_enums.WorkspaceRoleOwner) + assert.Contains(t, roles, users_enums.WorkspaceRoleViewer) + + // 6. Promote member to admin + promoteRequest := workspaces_dto.ChangeMemberRoleRequestDTO{ + Role: users_enums.WorkspaceRoleMember, + } + + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspaceID.String()+"/members/"+existingMember.UserID.String()+"/role", + "Bearer "+owner.Token, + promoteRequest, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "Member role changed successfully") + + // 7. Update workspace settings + updateRequest := workspaces_models.Workspace{ + Name: "Updated E2E Workspace", + } + + var updateResponse workspaces_models.Workspace + test_utils.MakePutRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/"+workspaceID.String(), + "Bearer "+owner.Token, + updateRequest, + http.StatusOK, + &updateResponse, + ) + + assert.Equal(t, "Updated E2E Workspace", updateResponse.Name) + + // 8. Transfer ownership + transferRequest := workspaces_dto.TransferOwnershipRequestDTO{ + NewOwnerEmail: existingMember.Email, + } + + resp = test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspaceID.String()+"/transfer-ownership", + "Bearer "+owner.Token, + transferRequest, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "Ownership transferred successfully") + + // 9. New owner can now manage workspace + var finalWorkspace workspaces_models.Workspace + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/"+workspaceID.String(), + "Bearer "+existingMember.Token, + http.StatusOK, + &finalWorkspace, + ) + + assert.Equal(t, workspaceID, finalWorkspace.ID) + assert.Equal(t, "Updated E2E Workspace", finalWorkspace.Name) + + // 10. New owner can delete workspace + resp = test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "DELETE", + URL: "/api/v1/workspaces/" + workspaceID.String(), + AuthToken: "Bearer " + existingMember.Token, + ExpectedStatus: http.StatusOK, + }) + + assert.Contains(t, string(resp.Body), "Workspace deleted successfully") +} + +func Test_AdminWorkspaceManagementE2E_CompletesSuccessfully(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + + // 1. Create admin and regular user + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + regularUser := users_testing.CreateTestUser(users_enums.UserRoleMember) + + // 2. Regular user creates workspace (with member creation disabled) + users_testing.DisableMemberWorkspaceCreation() + defer users_testing.ResetSettingsToDefaults() + + // Regular user cannot create workspace + createRequest := workspaces_dto.CreateWorkspaceRequestDTO{ + Name: "Regular User Workspace", + } + + test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces", + "Bearer "+regularUser.Token, + createRequest, + http.StatusForbidden, + ) + + // 3. Admin can create workspace regardless of settings + adminCreateRequest := workspaces_dto.CreateWorkspaceRequestDTO{ + Name: "Admin Workspace", + } + + var adminWorkspaceResponse workspaces_dto.WorkspaceResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces", + "Bearer "+admin.Token, + adminCreateRequest, + http.StatusOK, + &adminWorkspaceResponse, + ) + + assert.Equal(t, "Admin Workspace", adminWorkspaceResponse.Name) + adminWorkspaceID := adminWorkspaceResponse.ID + + // 4. Admin can view any workspace (even not a member) + regularUser2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + users_testing.EnableMemberWorkspaceCreation() + + regularUserCreateRequest := workspaces_dto.CreateWorkspaceRequestDTO{ + Name: "Regular User Workspace 2", + } + + var regularWorkspaceResponse workspaces_dto.WorkspaceResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces", + "Bearer "+regularUser2.Token, + regularUserCreateRequest, + http.StatusOK, + ®ularWorkspaceResponse, + ) + + regularWorkspaceID := regularWorkspaceResponse.ID + + // Admin can view regular user's workspace + var adminViewResponse workspaces_models.Workspace + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/"+regularWorkspaceID.String(), + "Bearer "+admin.Token, + http.StatusOK, + &adminViewResponse, + ) + + assert.Equal(t, regularWorkspaceID, adminViewResponse.ID) + + // 5. Admin can delete any workspace + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "DELETE", + URL: "/api/v1/workspaces/" + regularWorkspaceID.String(), + AuthToken: "Bearer " + admin.Token, + ExpectedStatus: http.StatusOK, + }) + + assert.Contains(t, string(resp.Body), "Workspace deleted successfully") + + // 6. Clean up admin's workspace + test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "DELETE", + URL: "/api/v1/workspaces/" + adminWorkspaceID.String(), + AuthToken: "Bearer " + admin.Token, + ExpectedStatus: http.StatusOK, + }) +} diff --git a/backend/internal/features/workspaces/controllers/membership_controller.go b/backend/internal/features/workspaces/controllers/membership_controller.go new file mode 100644 index 0000000..e1ceda7 --- /dev/null +++ b/backend/internal/features/workspaces/controllers/membership_controller.go @@ -0,0 +1,265 @@ +package workspaces_controllers + +import ( + "net/http" + + users_middleware "postgresus-backend/internal/features/users/middleware" + workspaces_dto "postgresus-backend/internal/features/workspaces/dto" + workspaces_services "postgresus-backend/internal/features/workspaces/services" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type MembershipController struct { + membershipService *workspaces_services.MembershipService +} + +func (c *MembershipController) RegisterRoutes(router *gin.RouterGroup) { + workspaceRoutes := router.Group("/workspaces/memberships/:id") + + workspaceRoutes.GET("/members", c.ListMembers) + workspaceRoutes.POST("/members", c.AddMember) + workspaceRoutes.PUT("/members/:userId/role", c.ChangeMemberRole) + workspaceRoutes.DELETE("/members/:userId", c.RemoveMember) + workspaceRoutes.POST("/transfer-ownership", c.TransferOwnership) +} + +// ListMembers +// @Summary List workspace members +// @Description Get list of all workspace members +// @Tags workspace-membership +// @Produce json +// @Security BearerAuth +// @Param id path string true "Workspace ID" +// @Success 200 {object} workspaces_dto.GetMembersResponseDTO +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /workspaces/memberships/{id}/members [get] +func (c *MembershipController) ListMembers(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + workspaceIDStr := ctx.Param("id") + workspaceID, err := uuid.Parse(workspaceIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workspace ID"}) + return + } + + response, err := c.membershipService.GetMembers(workspaceID, user) + if err != nil { + if err.Error() == "insufficient permissions to view workspace members" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, response) +} + +// AddMember +// @Summary Add member to workspace (supports both existing and new users) +// @Description Add an existing user to the workspace or invite a new user if they don't exist +// @Tags workspace-membership +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Workspace ID" +// @Param request body workspaces_dto.AddMemberRequestDTO true "Member addition data" +// @Success 200 {object} workspaces_dto.AddMemberResponseDTO +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /workspaces/memberships/{id}/members [post] +func (c *MembershipController) AddMember(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + workspaceIDStr := ctx.Param("id") + workspaceID, err := uuid.Parse(workspaceIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workspace ID"}) + return + } + + var request workspaces_dto.AddMemberRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + if !request.Role.IsValid() { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"}) + return + } + + 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" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, response) +} + +// ChangeMemberRole +// @Summary Change member role +// @Description Change the role of an existing workspace member +// @Tags workspace-membership +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Workspace ID" +// @Param userId path string true "User ID" +// @Param request body workspaces_dto.ChangeMemberRoleRequestDTO true "Role change data" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /workspaces/memberships/{id}/members/{userId}/role [put] +func (c *MembershipController) ChangeMemberRole(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + workspaceIDStr := ctx.Param("id") + workspaceID, err := uuid.Parse(workspaceIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workspace ID"}) + return + } + + userIDStr := ctx.Param("userId") + userID, err := uuid.Parse(userIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + var request workspaces_dto.ChangeMemberRoleRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + 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" { + 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": "Member role changed successfully"}) +} + +// RemoveMember +// @Summary Remove member from workspace +// @Description Remove a member from the workspace +// @Tags workspace-membership +// @Security BearerAuth +// @Param id path string true "Workspace ID" +// @Param userId path string true "User ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /workspaces/memberships/{id}/members/{userId} [delete] +func (c *MembershipController) RemoveMember(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + workspaceIDStr := ctx.Param("id") + workspaceID, err := uuid.Parse(workspaceIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workspace ID"}) + return + } + + userIDStr := ctx.Param("userId") + userID, err := uuid.Parse(userIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + 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" { + 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": "Member removed successfully"}) +} + +// TransferOwnership +// @Summary Transfer workspace ownership +// @Description Transfer workspace ownership to another workspace admin +// @Tags workspace-membership +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Workspace ID" +// @Param request body workspaces_dto.TransferOwnershipRequestDTO true "Ownership transfer data" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /workspaces/memberships/{id}/transfer-ownership [post] +func (c *MembershipController) TransferOwnership(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + workspaceIDStr := ctx.Param("id") + workspaceID, err := uuid.Parse(workspaceIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workspace ID"}) + return + } + + var request workspaces_dto.TransferOwnershipRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + if err := c.membershipService.TransferOwnership(workspaceID, &request, user); err != nil { + if err.Error() == "only workspace owner or admin can transfer ownership" { + 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": "Ownership transferred successfully"}) +} diff --git a/backend/internal/features/workspaces/controllers/membership_controller_test.go b/backend/internal/features/workspaces/controllers/membership_controller_test.go new file mode 100644 index 0000000..c3f64f2 --- /dev/null +++ b/backend/internal/features/workspaces/controllers/membership_controller_test.go @@ -0,0 +1,1315 @@ +package workspaces_controllers + +import ( + "fmt" + "net/http" + "strings" + "testing" + + users_enums "postgresus-backend/internal/features/users/enums" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_dto "postgresus-backend/internal/features/workspaces/dto" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +// ListMembers Tests + +func Test_GetWorkspaceMembers_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can view members", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can view members", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can view members", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace viewer can view members", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-member cannot view members", + workspaceRole: nil, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "global admin can view members", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI( + "Test Workspace", + owner, + router, + ) + + 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + member, + *tt.workspaceRole, + router, + ) + testUserToken = member.Token + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUserToken = nonMember.Token + } + + if tt.expectSuccess { + var response workspaces_dto.GetMembersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+testUserToken, + tt.expectedStatusCode, + &response, + ) + + assert.GreaterOrEqual(t, len(response.Members), 1) + } else { + resp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+testUserToken, + tt.expectedStatusCode, + ) + assert.Contains(t, string(resp.Body), "insufficient permissions to view workspace members") + } + }) + } +} + +func Test_GetWorkspaceMembers_WithInvalidWorkspaceID_ReturnsBadRequest(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + user := users_testing.CreateTestUser(users_enums.UserRoleMember) + + resp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/workspaces/memberships/invalid-uuid/members", + "Bearer "+user.Token, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "Invalid workspace ID") +} + +// AddMember Tests + +func Test_AddMemberToWorkspace_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + requesterRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can add member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can add member", + requesterRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can add member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member cannot add member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace viewer cannot add member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + newMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI( + "Test Workspace", + owner, + router, + ) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.requesterRole != nil && *tt.requesterRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else if tt.requesterRole != nil { + requester := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + requester, + *tt.requesterRole, + router, + ) + testUserToken = requester.Token + } + + request := workspaces_dto.AddMemberRequestDTO{ + Email: newMember.Email, + Role: users_enums.WorkspaceRoleViewer, + } + + if tt.expectSuccess { + var response workspaces_dto.AddMemberResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + &response, + ) + + assert.True(t, response.Status == workspaces_dto.AddStatusAdded) + } else { + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + ) + assert.Contains(t, string(resp.Body), "insufficient permissions to manage members") + } + }) + } +} + +func Test_AddMemberToWorkspace_WhenUserIsAlreadyMember_ReturnsBadRequest(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + member, + users_enums.WorkspaceRoleViewer, + router, + ) + + // Try to add the same user again + request := workspaces_dto.AddMemberRequestDTO{ + Email: member.Email, + Role: users_enums.WorkspaceRoleViewer, + } + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+owner.Token, + request, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "user is already a member of this workspace") +} + +func Test_AddMemberToWorkspace_WithNonExistentUser_ReturnsInvited(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router) + + request := workspaces_dto.AddMemberRequestDTO{ + Email: uuid.New().String() + "@example.com", // Non-existent user + Role: users_enums.WorkspaceRoleViewer, + } + + var response workspaces_dto.AddMemberResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+owner.Token, + request, + http.StatusOK, + &response, + ) + + assert.True(t, response.Status == workspaces_dto.AddStatusInvited) +} + +func Test_AddMemberToWorkspace_WhenWorkspaceAdminTriesToAddAdmin_ReturnsBadRequest(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaceAdmin := users_testing.CreateTestUser(users_enums.UserRoleMember) + newMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + workspaceAdmin, + users_enums.WorkspaceRoleAdmin, + router, + ) + + // Workspace admin tries to add another admin (should fail) + request := workspaces_dto.AddMemberRequestDTO{ + Email: newMember.Email, + Role: users_enums.WorkspaceRoleAdmin, + } + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+workspaceAdmin.Token, + request, + http.StatusForbidden, + ) + assert.Contains(t, string(resp.Body), "only workspace owner can add/manage admins") +} + +func Test_AddMemberToWorkspace_WhenWorkspaceAdminTriesToAddWorkspaceAdmin_ReturnsBadRequest( + t *testing.T, +) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaceAdmin := users_testing.CreateTestUser(users_enums.UserRoleMember) + newMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + workspaceAdmin, + users_enums.WorkspaceRoleAdmin, + router, + ) + + // WorkspaceAdmin tries to add another WorkspaceAdmin (should fail) + request := workspaces_dto.AddMemberRequestDTO{ + Email: newMember.Email, + Role: users_enums.WorkspaceRoleAdmin, + } + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+workspaceAdmin.Token, + request, + http.StatusForbidden, + ) + assert.Contains(t, string(resp.Body), "only workspace owner can add/manage admins") +} + +func Test_AddWorkspaceAdmin_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + requesterRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can add workspace admin", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can add workspace admin", + requesterRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin cannot add workspace admin", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace member cannot add workspace admin", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace viewer cannot add workspace admin", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + newMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI( + "Test Workspace", + owner, + router, + ) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.requesterRole != nil && *tt.requesterRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else if tt.requesterRole != nil { + requester := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + requester, + *tt.requesterRole, + router, + ) + testUserToken = requester.Token + } + + request := workspaces_dto.AddMemberRequestDTO{ + Email: newMember.Email, + Role: users_enums.WorkspaceRoleAdmin, + } + + if tt.expectSuccess { + var response workspaces_dto.AddMemberResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + &response, + ) + + assert.True(t, response.Status == workspaces_dto.AddStatusAdded) + } else { + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + ) + assert.Contains(t, string(resp.Body), "only workspace owner can add/manage admins") + } + }) + } +} + +func Test_InviteMemberToWorkspace_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + requesterRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can invite member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can invite member", + requesterRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can invite member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member cannot invite member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace viewer cannot invite member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + users_testing.EnableMemberInvitations() + defer users_testing.ResetSettingsToDefaults() + + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI( + "Test Workspace", + owner, + router, + ) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.requesterRole != nil && *tt.requesterRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else if tt.requesterRole != nil { + requester := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + requester, + *tt.requesterRole, + router, + ) + testUserToken = requester.Token + } + + request := workspaces_dto.AddMemberRequestDTO{ + Email: fmt.Sprintf("invite-%s@example.com", uuid.New().String()), + Role: users_enums.WorkspaceRoleViewer, + } + + if tt.expectSuccess { + var response workspaces_dto.AddMemberResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + &response, + ) + + assert.True(t, response.Status == workspaces_dto.AddStatusInvited) + } else { + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + ) + assert.Contains(t, string(resp.Body), "insufficient permissions to manage members") + } + }) + } +} + +// ChangeMemberRole Tests + +func Test_ChangeMemberRole_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + requesterRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can change member role", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can change member role", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can change member role", + requesterRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member cannot change member role", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace viewer cannot change member role", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + targetMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI( + "Test Workspace", + owner, + router, + ) + + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + targetMember, + users_enums.WorkspaceRoleViewer, + router, + ) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.requesterRole != nil && *tt.requesterRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else if tt.requesterRole != nil { + requester := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + requester, + *tt.requesterRole, + router, + ) + testUserToken = requester.Token + } + + request := workspaces_dto.ChangeMemberRoleRequestDTO{ + Role: users_enums.WorkspaceRoleViewer, + } + + resp := test_utils.MakePutRequest( + t, + router, + fmt.Sprintf( + "/api/v1/workspaces/memberships/%s/members/%s/role", + workspace.ID.String(), + targetMember.UserID.String(), + ), + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + ) + + if tt.expectSuccess { + assert.Contains(t, string(resp.Body), "Member role changed successfully") + } else { + assert.Contains(t, string(resp.Body), "insufficient permissions to manage members") + } + }) + } +} + +func Test_ChangeMemberRoleToAdmin_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + requesterRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can promote to admin", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can promote to admin", + requesterRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin cannot promote to admin", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace member cannot promote to admin", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + targetMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI( + "Test Workspace", + owner, + router, + ) + + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + targetMember, + users_enums.WorkspaceRoleViewer, + router, + ) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.requesterRole != nil && *tt.requesterRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else if tt.requesterRole != nil { + requester := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + requester, + *tt.requesterRole, + router, + ) + testUserToken = requester.Token + } + + request := workspaces_dto.ChangeMemberRoleRequestDTO{ + Role: users_enums.WorkspaceRoleAdmin, + } + + resp := test_utils.MakePutRequest( + t, + router, + fmt.Sprintf( + "/api/v1/workspaces/memberships/%s/members/%s/role", + workspace.ID.String(), + targetMember.UserID.String(), + ), + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + ) + + if tt.expectSuccess { + assert.Contains(t, string(resp.Body), "Member role changed successfully") + } else { + assert.Contains(t, string(resp.Body), "only workspace owner can add/manage admins") + } + }) + } +} + +func Test_ChangeMemberRole_WhenChangingOwnRole_ReturnsBadRequest(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router) + + request := workspaces_dto.ChangeMemberRoleRequestDTO{ + Role: users_enums.WorkspaceRoleMember, + } + + resp := test_utils.MakePutRequest( + t, + router, + fmt.Sprintf( + "/api/v1/workspaces/memberships/%s/members/%s/role", + workspace.ID.String(), + owner.UserID.String(), + ), + "Bearer "+owner.Token, + request, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "cannot change your own role") +} + +func Test_ChangeMemberRole_WhenChangingOwnerRole_ReturnsBadRequest(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router) + + request := workspaces_dto.ChangeMemberRoleRequestDTO{ + Role: users_enums.WorkspaceRoleMember, + } + + resp := test_utils.MakePutRequest( + t, + router, + fmt.Sprintf( + "/api/v1/workspaces/memberships/%s/members/%s/role", + workspace.ID.String(), + owner.UserID.String(), + ), + "Bearer "+admin.Token, + request, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "cannot change owner role") +} + +// RemoveMember Tests + +func Test_RemoveMemberFromWorkspace_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + requesterRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can remove member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can remove member", + requesterRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can remove member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member cannot remove member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace viewer cannot remove member", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + targetMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI( + "Test Workspace", + owner, + router, + ) + + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + targetMember, + users_enums.WorkspaceRoleViewer, + router, + ) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.requesterRole != nil && *tt.requesterRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else if tt.requesterRole != nil { + requester := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + requester, + *tt.requesterRole, + router, + ) + testUserToken = requester.Token + } + + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "DELETE", + URL: fmt.Sprintf( + "/api/v1/workspaces/memberships/%s/members/%s", + workspace.ID.String(), + targetMember.UserID.String(), + ), + AuthToken: "Bearer " + testUserToken, + ExpectedStatus: tt.expectedStatusCode, + }) + + if tt.expectSuccess { + assert.Contains(t, string(resp.Body), "Member removed successfully") + } else { + assert.Contains(t, string(resp.Body), "insufficient permissions to remove members") + } + }) + } +} + +func Test_RemoveMemberFromWorkspace_WhenRemovingOwner_ReturnsBadRequest(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router) + + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "DELETE", + URL: fmt.Sprintf( + "/api/v1/workspaces/memberships/%s/members/%s", + workspace.ID.String(), + owner.UserID.String(), + ), + AuthToken: "Bearer " + admin.Token, + ExpectedStatus: http.StatusBadRequest, + }) + + assert.Contains(t, string(resp.Body), "cannot remove workspace owner, transfer ownership first") +} + +func Test_RemoveWorkspaceAdmin_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + requesterRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can remove workspace admin", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can remove workspace admin", + requesterRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin cannot remove workspace admin", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace member cannot remove workspace admin", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + targetAdmin := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI( + "Test Workspace", + owner, + router, + ) + + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + targetAdmin, + users_enums.WorkspaceRoleAdmin, + router, + ) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.requesterRole != nil && *tt.requesterRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else if tt.requesterRole != nil { + requester := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + requester, + *tt.requesterRole, + router, + ) + testUserToken = requester.Token + } + + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "DELETE", + URL: fmt.Sprintf( + "/api/v1/workspaces/memberships/%s/members/%s", + workspace.ID.String(), + targetAdmin.UserID.String(), + ), + AuthToken: "Bearer " + testUserToken, + ExpectedStatus: tt.expectedStatusCode, + }) + + if tt.expectSuccess { + assert.Contains(t, string(resp.Body), "Member removed successfully") + } else { + // Workspace admins get specific error about removing admins + // Members/viewers get generic insufficient permissions error + body := string(resp.Body) + assert.True(t, + strings.Contains(body, "only workspace owner can remove admins") || + strings.Contains(body, "insufficient permissions to remove members"), + "Expected permission error, got: %s", body) + } + }) + } +} + +// TransferOwnership Tests + +func Test_TransferWorkspaceOwnership_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + requesterRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can transfer ownership", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can transfer ownership", + requesterRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member cannot transfer ownership", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace admin cannot transfer ownership", + requesterRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + newOwner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI( + "Test Workspace", + owner, + router, + ) + + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + newOwner, + users_enums.WorkspaceRoleMember, + router, + ) + + var testUserToken string + if tt.isGlobalAdmin { + admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + testUserToken = admin.Token + } else if tt.requesterRole != nil && *tt.requesterRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else if tt.requesterRole != nil { + requester := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + requester, + *tt.requesterRole, + router, + ) + testUserToken = requester.Token + } + + request := workspaces_dto.TransferOwnershipRequestDTO{ + NewOwnerEmail: newOwner.Email, + } + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/transfer-ownership", + "Bearer "+testUserToken, + request, + tt.expectedStatusCode, + ) + + if tt.expectSuccess { + assert.Contains(t, string(resp.Body), "Ownership transferred successfully") + } else { + assert.Contains(t, string(resp.Body), "only workspace owner or admin can transfer ownership") + } + }) + } +} + +func Test_TransferWorkspaceOwnership_WhenNewOwnerIsNotMember_ReturnsBadRequest(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router) + + request := workspaces_dto.TransferOwnershipRequestDTO{ + NewOwnerEmail: nonMember.Email, + } + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/transfer-ownership", + "Bearer "+owner.Token, + request, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "new owner must be a workspace member") +} + +func Test_TransferWorkspaceOwnership_ThereIsOnlyOneOwner_OldOwnerBecomeAdmin(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + + workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router) + workspaces_testing.AddMemberToWorkspaceViaOwner( + workspace, + member, + users_enums.WorkspaceRoleMember, + router, + ) + + // Transfer ownership to the member + request := workspaces_dto.TransferOwnershipRequestDTO{ + NewOwnerEmail: member.Email, + } + + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/transfer-ownership", + "Bearer "+owner.Token, + request, + http.StatusOK, + ) + assert.Contains(t, string(resp.Body), "Ownership transferred successfully") + + // Get all members using the new owner's token + var membersResponse workspaces_dto.GetMembersResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+member.Token, + http.StatusOK, + &membersResponse, + ) + + // Verify there is only one owner + ownerCount := 0 + var currentOwner *workspaces_dto.WorkspaceMemberResponseDTO + for _, m := range membersResponse.Members { + if m.Role == users_enums.WorkspaceRoleOwner { + ownerCount++ + currentOwner = &m + } + } + + assert.Equal(t, 1, ownerCount, "There should be exactly one owner") + assert.NotNil(t, currentOwner, "Owner should exist") + assert.Equal( + t, + member.UserID, + currentOwner.UserID, + "The new owner should be the member we transferred to", + ) + assert.Equal( + t, + member.Email, + currentOwner.Email, + "Owner email should match the transferred member", + ) + + // verify previous owner is now an admin + for _, m := range membersResponse.Members { + if m.UserID == owner.UserID { + assert.Equal( + t, + users_enums.WorkspaceRoleAdmin, + m.Role, + "Previous owner should now be admin", + ) + break + } + } +} diff --git a/backend/internal/features/workspaces/controllers/workspace_controller.go b/backend/internal/features/workspaces/controllers/workspace_controller.go new file mode 100644 index 0000000..ab4b598 --- /dev/null +++ b/backend/internal/features/workspaces/controllers/workspace_controller.go @@ -0,0 +1,266 @@ +package workspaces_controllers + +import ( + "net/http" + + audit_logs "postgresus-backend/internal/features/audit_logs" + users_middleware "postgresus-backend/internal/features/users/middleware" + workspaces_dto "postgresus-backend/internal/features/workspaces/dto" + workspaces_models "postgresus-backend/internal/features/workspaces/models" + workspaces_services "postgresus-backend/internal/features/workspaces/services" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type WorkspaceController struct { + workspaceService *workspaces_services.WorkspaceService +} + +func (c *WorkspaceController) RegisterRoutes(router *gin.RouterGroup) { + workspaceRoutes := router.Group("/workspaces") + + workspaceRoutes.POST("", c.CreateWorkspace) + workspaceRoutes.GET("", c.GetWorkspaces) + workspaceRoutes.GET("/:id", c.GetWorkspace) + workspaceRoutes.PUT("/:id", c.UpdateWorkspace) + workspaceRoutes.DELETE("/:id", c.DeleteWorkspace) + workspaceRoutes.GET("/:id/audit-logs", c.GetWorkspaceAuditLogs) +} + +// CreateWorkspace +// @Summary Create a new workspace +// @Description Create a new workspace with default settings +// @Tags workspaces +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body workspaces_dto.CreateWorkspaceRequestDTO true "Workspace creation data" +// @Success 200 {object} workspaces_dto.WorkspaceResponseDTO +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /workspaces [post] +func (c *WorkspaceController) CreateWorkspace(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var request workspaces_dto.CreateWorkspaceRequestDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + response, err := c.workspaceService.CreateWorkspace(&request, user) + if err != nil { + if err.Error() == "insufficient permissions to create workspaces" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + + return + } + + ctx.JSON(http.StatusOK, response) +} + +// GetWorkspaces +// @Summary List user's workspaces +// @Description Get list of workspaces the user is a member of +// @Tags workspaces +// @Produce json +// @Security BearerAuth +// @Success 200 {object} workspaces_dto.ListWorkspacesResponseDTO +// @Failure 401 {object} map[string]string +// @Router /workspaces [get] +func (c *WorkspaceController) GetWorkspaces(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + response, err := c.workspaceService.GetUserWorkspaces(user) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workspaces"}) + return + } + + ctx.JSON(http.StatusOK, response) +} + +// GetWorkspace +// @Summary Get workspace details +// @Description Get detailed information about a specific workspace +// @Tags workspaces +// @Produce json +// @Security BearerAuth +// @Param id path string true "Workspace ID" +// @Success 200 {object} workspaces_models.Workspace +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /workspaces/{id} [get] +func (c *WorkspaceController) GetWorkspace(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + workspaceIDStr := ctx.Param("id") + workspaceID, err := uuid.Parse(workspaceIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workspace ID"}) + return + } + + workspace, err := c.workspaceService.GetWorkspace(workspaceID, user) + if err != nil { + if err.Error() == "insufficient permissions to view workspace" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, workspace) +} + +// UpdateWorkspace +// @Summary Update workspace settings +// @Description Update workspace configuration and settings +// @Tags workspaces +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Workspace ID" +// @Param request body workspaces_models.Workspace true "Workspace update data" +// @Success 200 {object} workspaces_models.Workspace +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /workspaces/{id} [put] +func (c *WorkspaceController) UpdateWorkspace(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + workspaceIDStr := ctx.Param("id") + workspaceID, err := uuid.Parse(workspaceIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workspace ID"}) + return + } + + var workspace workspaces_models.Workspace + if err := ctx.ShouldBindJSON(&workspace); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + updatedWorkspace, err := c.workspaceService.UpdateWorkspace(workspaceID, &workspace, user) + if err != nil { + if err.Error() == "insufficient permissions to update workspace" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, updatedWorkspace) +} + +// DeleteWorkspace +// @Summary Delete workspace +// @Description Delete a workspace (owner only) +// @Tags workspaces +// @Security BearerAuth +// @Param id path string true "Workspace ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /workspaces/{id} [delete] +func (c *WorkspaceController) DeleteWorkspace(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + workspaceIDStr := ctx.Param("id") + workspaceID, err := uuid.Parse(workspaceIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workspace ID"}) + return + } + + if err := c.workspaceService.DeleteWorkspace(workspaceID, user); err != nil { + if err.Error() == "only workspace owner or admin can delete workspace" { + 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": "Workspace deleted successfully"}) +} + +// GetWorkspaceAuditLogs +// @Summary Get workspace audit logs +// @Description Retrieve audit logs for a specific workspace (member access required) +// @Tags workspaces +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Workspace ID" +// @Param limit query int false "Limit number of results" default(100) +// @Param offset query int false "Offset for pagination" default(0) +// @Param beforeDate query string false "Filter logs created before this date (RFC3339 format)" format(date-time) +// @Success 200 {object} audit_logs.GetAuditLogsResponse +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /workspaces/{id}/audit-logs [get] +func (c *WorkspaceController) GetWorkspaceAuditLogs(ctx *gin.Context) { + user, ok := users_middleware.GetUserFromContext(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + workspaceIDStr := ctx.Param("id") + workspaceID, err := uuid.Parse(workspaceIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workspace ID"}) + return + } + + request := &audit_logs.GetAuditLogsRequest{} + if err := ctx.ShouldBindQuery(request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) + return + } + + response, err := c.workspaceService.GetWorkspaceAuditLogs(workspaceID, user, request) + if err != nil { + if err.Error() == "insufficient permissions to view workspace audit logs" { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, response) +} diff --git a/backend/internal/features/workspaces/controllers/workspace_controller_test.go b/backend/internal/features/workspaces/controllers/workspace_controller_test.go new file mode 100644 index 0000000..01dbbaa --- /dev/null +++ b/backend/internal/features/workspaces/controllers/workspace_controller_test.go @@ -0,0 +1,706 @@ +package workspaces_controllers + +import ( + "fmt" + "net/http" + "testing" + + audit_logs "postgresus-backend/internal/features/audit_logs" + users_dto "postgresus-backend/internal/features/users/dto" + users_enums "postgresus-backend/internal/features/users/enums" + users_models "postgresus-backend/internal/features/users/models" + users_services "postgresus-backend/internal/features/users/services" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_dto "postgresus-backend/internal/features/workspaces/dto" + workspaces_models "postgresus-backend/internal/features/workspaces/models" + workspaces_testing "postgresus-backend/internal/features/workspaces/testing" + test_utils "postgresus-backend/internal/util/testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func Test_CreateWorkspace_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + userRole users_enums.UserRole + memberCreationEnabled bool + expectSuccess bool + expectedStatusCode int + expectedWorkspaceName string + }{ + { + name: "member can create workspace when enabled", + userRole: users_enums.UserRoleMember, + memberCreationEnabled: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + expectedWorkspaceName: "Test Workspace", + }, + { + name: "member cannot create workspace when disabled", + userRole: users_enums.UserRoleMember, + memberCreationEnabled: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "admin can create workspace when member creation disabled", + userRole: users_enums.UserRoleAdmin, + memberCreationEnabled: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + expectedWorkspaceName: "Admin Workspace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + + if tt.memberCreationEnabled { + users_testing.EnableMemberWorkspaceCreation() + } else { + users_testing.DisableMemberWorkspaceCreation() + } + defer users_testing.ResetSettingsToDefaults() + + user := users_testing.CreateTestUser(tt.userRole) + + uniqueID := uuid.New().String()[:8] + workspaceName := tt.expectedWorkspaceName + " " + uniqueID + request := workspaces_dto.CreateWorkspaceRequestDTO{ + Name: workspaceName, + } + + if tt.expectSuccess { + var response workspaces_dto.WorkspaceResponseDTO + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces", + "Bearer "+user.Token, + request, + tt.expectedStatusCode, + &response, + ) + + assert.Equal(t, workspaceName, response.Name) + assert.NotEqual(t, uuid.Nil, response.ID) + assert.Equal(t, users_enums.WorkspaceRoleOwner, *response.UserRole) + } else { + resp := test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces", + "Bearer "+user.Token, + request, + tt.expectedStatusCode, + ) + assert.Contains(t, string(resp.Body), "insufficient permissions to create workspaces") + } + }) + } +} + +func Test_CreateWorkspace_WithInvalidJSON_ReturnsBadRequest(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + user := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "POST", + URL: "/api/v1/workspaces", + Body: "invalid json", + AuthToken: "Bearer " + user.Token, + ExpectedStatus: http.StatusBadRequest, + }) + + assert.Contains(t, string(resp.Body), "Invalid request format") +} + +func Test_CreateWorkspace_WithoutAuthToken_ReturnsUnauthorized(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + + request := workspaces_dto.CreateWorkspaceRequestDTO{ + Name: "Test Workspace", + } + + test_utils.MakePostRequest( + t, + router, + "/api/v1/workspaces", + "", + request, + http.StatusUnauthorized, + ) +} + +func Test_GetUserWorkspaces_WhenUserHasWorkspaces_ReturnsWorkspacesList(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + user := users_testing.CreateTestUser(users_enums.UserRoleMember) + + workspace1, _ := workspaces_testing.CreateTestWorkspaceWithToken( + "Workspace 1", + user.Token, + router, + ) + workspace2, _ := workspaces_testing.CreateTestWorkspaceWithToken( + "Workspace 2", + user.Token, + router, + ) + + var response workspaces_dto.ListWorkspacesResponseDTO + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces", + "Bearer "+user.Token, + http.StatusOK, + &response, + ) + + assert.GreaterOrEqual(t, len(response.Workspaces), 2) + + workspaceNames := make([]string, len(response.Workspaces)) + for i, w := range response.Workspaces { + workspaceNames[i] = w.Name + } + assert.Contains(t, workspaceNames, workspace1.Name) + assert.Contains(t, workspaceNames, workspace2.Name) +} + +func Test_GetUserWorkspaces_WithoutAuthToken_ReturnsUnauthorized(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + test_utils.MakeGetRequest(t, router, "/api/v1/workspaces", "", http.StatusUnauthorized) +} + +func Test_GetSingleWorkspace_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can get workspace", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can get workspace", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleAdmin; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member can get workspace", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace viewer can get workspace", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "non-member cannot get workspace", + workspaceRole: nil, + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "global admin can get workspace", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceWithToken( + "Test Workspace", + owner.Token, + router, + ) + + 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } else { + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + testUserToken = nonMember.Token + } + + if tt.expectSuccess { + var response workspaces_models.Workspace + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/"+workspace.ID.String(), + "Bearer "+testUserToken, + tt.expectedStatusCode, + &response, + ) + + assert.Equal(t, workspace.ID, response.ID) + assert.Equal(t, "Test Workspace", response.Name) + } else { + resp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/workspaces/"+workspace.ID.String(), + "Bearer "+testUserToken, + tt.expectedStatusCode, + ) + assert.Contains(t, string(resp.Body), "insufficient permissions to view workspace") + } + }) + } +} + +func Test_GetSingleWorkspace_WithInvalidWorkspaceID_ReturnsBadRequest(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + user := users_testing.CreateTestUser(users_enums.UserRoleMember) + + resp := test_utils.MakeGetRequest( + t, + router, + "/api/v1/workspaces/invalid-uuid", + "Bearer "+user.Token, + http.StatusBadRequest, + ) + assert.Contains(t, string(resp.Body), "Invalid workspace ID") +} + +func Test_UpdateWorkspace_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole users_enums.WorkspaceRole + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can update workspace", + workspaceRole: users_enums.WorkspaceRoleOwner, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace admin can update workspace", + workspaceRole: users_enums.WorkspaceRoleAdmin, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member cannot update workspace", + workspaceRole: users_enums.WorkspaceRoleMember, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace viewer cannot update workspace", + workspaceRole: users_enums.WorkspaceRoleViewer, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceWithToken( + "Original Name", + owner.Token, + router, + ) + + var testUserToken string + if tt.workspaceRole == users_enums.WorkspaceRoleOwner { + testUserToken = owner.Token + } else { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + + updateRequest := workspaces_models.Workspace{ + Name: "Updated Name", + } + + if tt.expectSuccess { + var response workspaces_models.Workspace + test_utils.MakePutRequestAndUnmarshal( + t, + router, + "/api/v1/workspaces/"+workspace.ID.String(), + "Bearer "+testUserToken, + updateRequest, + tt.expectedStatusCode, + &response, + ) + + assert.Equal(t, workspace.ID, response.ID) + assert.Equal(t, "Updated Name", response.Name) + } else { + resp := test_utils.MakePutRequest( + t, + router, + "/api/v1/workspaces/"+workspace.ID.String(), + "Bearer "+testUserToken, + updateRequest, + tt.expectedStatusCode, + ) + assert.Contains(t, string(resp.Body), "insufficient permissions to update workspace") + } + }) + } +} + +func Test_DeleteWorkspace_PermissionsEnforced(t *testing.T) { + tests := []struct { + name string + workspaceRole *users_enums.WorkspaceRole + isGlobalAdmin bool + expectSuccess bool + expectedStatusCode int + }{ + { + name: "workspace owner can delete workspace", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleOwner; return &r }(), + isGlobalAdmin: false, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "global admin can delete workspace", + workspaceRole: nil, + isGlobalAdmin: true, + expectSuccess: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "workspace member cannot delete workspace", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + { + name: "workspace admin cannot delete workspace", + workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), + isGlobalAdmin: false, + expectSuccess: false, + expectedStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace, _ := workspaces_testing.CreateTestWorkspaceWithToken( + "Test Workspace", + owner.Token, + router, + ) + + 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 { + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + testUserToken = member.Token + } + + resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{ + Method: "DELETE", + URL: "/api/v1/workspaces/" + workspace.ID.String(), + AuthToken: "Bearer " + testUserToken, + ExpectedStatus: tt.expectedStatusCode, + }) + + if tt.expectSuccess { + assert.Contains(t, string(resp.Body), "Workspace deleted successfully") + } else { + assert.Contains(t, string(resp.Body), "only workspace owner or admin can delete workspace") + } + }) + } +} + +func Test_GetWorkspaceAuditLogs_WhenUserIsWorkspaceAdmin_ReturnsAuditLogs(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspaceAdmin := users_testing.CreateTestUser(users_enums.UserRoleMember) + + uniqueID := uuid.New() + workspaceName := fmt.Sprintf("WorkspaceAdmin Test %s", uniqueID.String()[:8]) + workspace, _ := workspaces_testing.CreateTestWorkspaceWithToken( + workspaceName, + owner.Token, + router, + ) + + workspaces_testing.AddMemberToWorkspace( + workspace, + workspaceAdmin, + users_enums.WorkspaceRoleMember, + owner.Token, + router, + ) + var response audit_logs.GetAuditLogsResponse + test_utils.MakeGetRequestAndUnmarshal(t, router, + "/api/v1/workspaces/"+workspace.ID.String()+"/audit-logs", + "Bearer "+workspaceAdmin.Token, http.StatusOK, &response) + + assert.GreaterOrEqual(t, len(response.AuditLogs), 2) // Create + Add member + for _, log := range response.AuditLogs { + assert.Equal(t, &workspace.ID, log.WorkspaceID) + } +} + +func Test_GetWorkspaceAuditLogs_WithMultipleWorkspaces_ReturnsOnlyWorkspaceSpecificLogs( + t *testing.T, +) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember) + owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember) + + uniqueID1 := uuid.New() + uniqueID2 := uuid.New() + workspaceName1 := fmt.Sprintf("Workspace Test %s", uniqueID1.String()[:8]) + workspaceName2 := fmt.Sprintf("Workspace Test %s", uniqueID2.String()[:8]) + + workspace1, _ := workspaces_testing.CreateTestWorkspaceWithToken( + workspaceName1, + owner1.Token, + router, + ) + workspace2, _ := workspaces_testing.CreateTestWorkspaceWithToken( + workspaceName2, + owner2.Token, + router, + ) + + updateWorkspace1 := workspaces_models.Workspace{ + Name: "Updated " + workspace1.Name, + } + test_utils.MakePutRequest( + t, + router, + "/api/v1/workspaces/"+workspace1.ID.String(), + "Bearer "+owner1.Token, + updateWorkspace1, + http.StatusOK, + ) + + updateWorkspace2 := workspaces_models.Workspace{ + Name: "Updated " + workspace2.Name, + } + test_utils.MakePutRequest( + t, + router, + "/api/v1/workspaces/"+workspace2.ID.String(), + "Bearer "+owner2.Token, + updateWorkspace2, + http.StatusOK, + ) + + var workspace1Response audit_logs.GetAuditLogsResponse + test_utils.MakeGetRequestAndUnmarshal(t, router, + "/api/v1/workspaces/"+workspace1.ID.String()+"/audit-logs?limit=50", + "Bearer "+owner1.Token, http.StatusOK, &workspace1Response) + + var workspace2Response audit_logs.GetAuditLogsResponse + test_utils.MakeGetRequestAndUnmarshal(t, router, + "/api/v1/workspaces/"+workspace2.ID.String()+"/audit-logs?limit=50", + "Bearer "+owner2.Token, http.StatusOK, &workspace2Response) + + assert.GreaterOrEqual(t, len(workspace1Response.AuditLogs), 2) + for _, log := range workspace1Response.AuditLogs { + assert.Equal(t, &workspace1.ID, log.WorkspaceID) + assert.Contains(t, log.Message, workspaceName1) + } + + assert.GreaterOrEqual(t, len(workspace2Response.AuditLogs), 2) + for _, log := range workspace2Response.AuditLogs { + assert.Equal(t, &workspace2.ID, log.WorkspaceID) + assert.Contains(t, log.Message, workspaceName2) + } + + workspace1Messages := extractAuditLogMessages(workspace1Response.AuditLogs) + workspace2Messages := extractAuditLogMessages(workspace2Response.AuditLogs) + + for _, msg := range workspace1Messages { + assert.NotContains( + t, + msg, + workspaceName2, + "Workspace1 logs should not contain Workspace2 name", + ) + } + + for _, msg := range workspace2Messages { + assert.NotContains( + t, + msg, + workspaceName1, + "Workspace2 logs should not contain Workspace1 name", + ) + } +} + +func Test_GetWorkspaceAuditLogs_WithDifferentUserRoles_EnforcesPermissionsCorrectly(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + globalAdmin := users_testing.CreateTestUser(users_enums.UserRoleAdmin) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + member := users_testing.CreateTestUser(users_enums.UserRoleMember) + nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) + + uniqueID := uuid.New() + workspaceName := fmt.Sprintf("Audit Test Workspace %s", uniqueID.String()[:8]) + workspace, _ := workspaces_testing.CreateTestWorkspaceWithToken( + workspaceName, + owner.Token, + router, + ) + + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + users_enums.WorkspaceRoleViewer, + owner.Token, + router, + ) + var ownerResponse audit_logs.GetAuditLogsResponse + test_utils.MakeGetRequestAndUnmarshal(t, router, + "/api/v1/workspaces/"+workspace.ID.String()+"/audit-logs", + "Bearer "+owner.Token, http.StatusOK, &ownerResponse) + + assert.GreaterOrEqual(t, len(ownerResponse.AuditLogs), 2) + var memberResponse audit_logs.GetAuditLogsResponse + test_utils.MakeGetRequestAndUnmarshal(t, router, + "/api/v1/workspaces/"+workspace.ID.String()+"/audit-logs", + "Bearer "+member.Token, http.StatusOK, &memberResponse) + + assert.GreaterOrEqual(t, len(memberResponse.AuditLogs), 2) + + var globalAdminResponse audit_logs.GetAuditLogsResponse + test_utils.MakeGetRequestAndUnmarshal(t, router, + "/api/v1/workspaces/"+workspace.ID.String()+"/audit-logs", + "Bearer "+globalAdmin.Token, http.StatusOK, &globalAdminResponse) + + assert.GreaterOrEqual(t, len(globalAdminResponse.AuditLogs), 2) + + resp := test_utils.MakeGetRequest(t, router, + "/api/v1/workspaces/"+workspace.ID.String()+"/audit-logs", + "Bearer "+nonMember.Token, http.StatusForbidden) + + assert.Contains(t, string(resp.Body), "insufficient permissions to view workspace audit logs") +} + +func Test_GetWorkspaceAuditLogs_WithoutAuthToken_ReturnsUnauthorized(t *testing.T) { + router := workspaces_testing.CreateTestRouter( + GetWorkspaceController(), + GetMembershipController(), + ) + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + + workspace, _ := workspaces_testing.CreateTestWorkspaceWithToken( + "Test Workspace", + owner.Token, + router, + ) + + test_utils.MakeGetRequest(t, router, + "/api/v1/workspaces/"+workspace.ID.String()+"/audit-logs", + "", http.StatusUnauthorized) +} + +func extractAuditLogMessages(logs []*audit_logs.AuditLogDTO) []string { + messages := make([]string, len(logs)) + for i, log := range logs { + messages[i] = log.Message + } + return messages +} + +func getUserFromSignInResponse(response *users_dto.SignInResponseDTO) *users_models.User { + userService := users_services.GetUserService() + user, err := userService.GetUserByID(response.UserID) + if err != nil { + panic(err) + } + return user +} + +type AuditLogWriterStub struct{} + +func (a *AuditLogWriterStub) WriteAuditLog( + message string, + userID *uuid.UUID, + workspaceID *uuid.UUID, +) { +} diff --git a/backend/internal/features/workspaces/dto/dto.go b/backend/internal/features/workspaces/dto/dto.go new file mode 100644 index 0000000..c0cfcdc --- /dev/null +++ b/backend/internal/features/workspaces/dto/dto.go @@ -0,0 +1,65 @@ +package workspaces_dto + +import ( + "time" + + users_enums "postgresus-backend/internal/features/users/enums" + + "github.com/google/uuid" +) + +type AddMemberStatus string + +const ( + AddStatusInvited AddMemberStatus = "INVITED" + AddStatusAdded AddMemberStatus = "ADDED" +) + +// Workspace DTOs +type CreateWorkspaceRequestDTO struct { + Name string `json:"name" binding:"required,min=1,max=255"` +} + +type WorkspaceResponseDTO struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + + // User's role in this workspace (populated when fetching for specific user) + UserRole *users_enums.WorkspaceRole `json:"userRole,omitempty"` +} + +type ListWorkspacesResponseDTO struct { + Workspaces []WorkspaceResponseDTO `json:"workspaces"` +} + +// Membership DTOs +type AddMemberRequestDTO struct { + Email string `json:"email" binding:"required,email"` + Role users_enums.WorkspaceRole `json:"role" binding:"required"` +} + +type AddMemberResponseDTO struct { + Status AddMemberStatus `json:"status"` +} + +type ChangeMemberRoleRequestDTO struct { + Role users_enums.WorkspaceRole `json:"role" binding:"required"` +} + +type TransferOwnershipRequestDTO struct { + NewOwnerEmail string `json:"newOwnerEmail" binding:"required"` +} + +type WorkspaceMemberResponseDTO struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"userId"` + Email string `json:"email"` // Populated from user join + Name string `json:"name"` // Populated from user join + Role users_enums.WorkspaceRole `json:"role"` + CreatedAt time.Time `json:"createdAt"` +} + +type GetMembersResponseDTO struct { + Members []WorkspaceMemberResponseDTO `json:"members"` +} diff --git a/backend/internal/features/workspaces/interfaces/interfaces.go b/backend/internal/features/workspaces/interfaces/interfaces.go new file mode 100644 index 0000000..20345d7 --- /dev/null +++ b/backend/internal/features/workspaces/interfaces/interfaces.go @@ -0,0 +1,7 @@ +package workspaces_interfaces + +import "github.com/google/uuid" + +type WorkspaceDeletionListener interface { + OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error +} diff --git a/backend/internal/features/workspaces/models/workspace.go b/backend/internal/features/workspaces/models/workspace.go new file mode 100644 index 0000000..4fddec5 --- /dev/null +++ b/backend/internal/features/workspaces/models/workspace.go @@ -0,0 +1,21 @@ +package workspaces_models + +import ( + "time" + + "github.com/google/uuid" +) + +type Workspace struct { + ID uuid.UUID `json:"id" gorm:"column:id"` + Name string `json:"name" gorm:"column:name"` + CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` +} + +func (Workspace) TableName() string { + return "workspaces" +} + +func (p *Workspace) UpdateFromDTO(updateDTO *Workspace) { + p.Name = updateDTO.Name +} diff --git a/backend/internal/features/workspaces/models/workspace_membership.go b/backend/internal/features/workspaces/models/workspace_membership.go new file mode 100644 index 0000000..702ea35 --- /dev/null +++ b/backend/internal/features/workspaces/models/workspace_membership.go @@ -0,0 +1,21 @@ +package workspaces_models + +import ( + "time" + + users_enums "postgresus-backend/internal/features/users/enums" + + "github.com/google/uuid" +) + +type WorkspaceMembership struct { + ID uuid.UUID `json:"id" gorm:"column:id"` + UserID uuid.UUID `json:"userId" gorm:"column:user_id"` + WorkspaceID uuid.UUID `json:"workspaceId" gorm:"column:workspace_id"` + Role users_enums.WorkspaceRole `json:"role" gorm:"column:role"` + CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` +} + +func (WorkspaceMembership) TableName() string { + return "workspace_memberships" +} diff --git a/backend/internal/features/workspaces/repositories/membership_repository.go b/backend/internal/features/workspaces/repositories/membership_repository.go new file mode 100644 index 0000000..09ea404 --- /dev/null +++ b/backend/internal/features/workspaces/repositories/membership_repository.go @@ -0,0 +1,133 @@ +package workspaces_repositories + +import ( + "errors" + "time" + + users_enums "postgresus-backend/internal/features/users/enums" + workspaces_dto "postgresus-backend/internal/features/workspaces/dto" + workspaces_models "postgresus-backend/internal/features/workspaces/models" + "postgresus-backend/internal/storage" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type MembershipRepository struct{} + +func (r *MembershipRepository) CreateMembership( + membership *workspaces_models.WorkspaceMembership, +) error { + if membership.ID == uuid.Nil { + membership.ID = uuid.New() + } + + if membership.CreatedAt.IsZero() { + membership.CreatedAt = time.Now().UTC() + } + + return storage.GetDb().Create(membership).Error +} + +func (r *MembershipRepository) GetMembershipByUserAndWorkspace( + userID, workspaceID uuid.UUID, +) (*workspaces_models.WorkspaceMembership, error) { + var membership workspaces_models.WorkspaceMembership + + if err := storage.GetDb(). + Where("user_id = ? AND workspace_id = ?", userID, workspaceID). + First(&membership).Error; err != nil { + return nil, err + } + + return &membership, nil +} + +func (r *MembershipRepository) GetWorkspaceMembers( + workspaceID uuid.UUID, +) ([]*workspaces_dto.WorkspaceMemberResponseDTO, error) { + var members []*workspaces_dto.WorkspaceMemberResponseDTO + + err := storage.GetDb(). + Table("workspace_memberships wm"). + Select("wm.id, wm.user_id, u.email, u.name, wm.role, wm.created_at"). + Joins("JOIN users u ON wm.user_id = u.id"). + Where("wm.workspace_id = ?", workspaceID). + Order("wm.created_at ASC"). + Scan(&members).Error + + return members, err +} + +func (r *MembershipRepository) UpdateMemberRole( + userID, workspaceID uuid.UUID, + role users_enums.WorkspaceRole, +) error { + return storage.GetDb(). + Model(&workspaces_models.WorkspaceMembership{}). + Where("user_id = ? AND workspace_id = ?", userID, workspaceID). + Update("role", role).Error +} + +func (r *MembershipRepository) RemoveMember(userID, workspaceID uuid.UUID) error { + return storage.GetDb(). + Where("user_id = ? AND workspace_id = ?", userID, workspaceID). + Delete(&workspaces_models.WorkspaceMembership{}).Error +} + +func (r *MembershipRepository) GetUserWorkspaceRole( + workspaceID, userID uuid.UUID, +) (*users_enums.WorkspaceRole, error) { + var membership workspaces_models.WorkspaceMembership + err := storage.GetDb(). + Where("workspace_id = ? AND user_id = ?", workspaceID, userID). + First(&membership).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + + return nil, err + } + + return &membership.Role, nil +} + +func (r *MembershipRepository) GetWorkspaceOwner( + workspaceID uuid.UUID, +) (*workspaces_models.WorkspaceMembership, error) { + var membership workspaces_models.WorkspaceMembership + + err := storage.GetDb(). + Where("workspace_id = ? AND role = ?", workspaceID, users_enums.WorkspaceRoleOwner). + First(&membership).Error + + if err != nil { + return nil, err + } + + return &membership, nil +} + +func (r *MembershipRepository) GetWorkspacesWithRolesByUserID( + userRole users_enums.UserRole, + userID uuid.UUID, +) ([]workspaces_dto.WorkspaceResponseDTO, error) { + results := make([]workspaces_dto.WorkspaceResponseDTO, 0) + + if userRole == users_enums.UserRoleAdmin { + err := storage.GetDb().Table("workspaces").Order("name ASC").Scan(&results).Error + return results, err + } + + err := storage.GetDb(). + Table("workspaces w"). + Select("w.id, w.name, w.created_at, wm.role as user_role"). + Joins("JOIN workspace_memberships wm ON w.id = wm.workspace_id"). + Where("wm.user_id = ?", userID). + Order("w.name ASC"). + Scan(&results).Error + + return results, err +} diff --git a/backend/internal/features/workspaces/repositories/workspace_repository.go b/backend/internal/features/workspaces/repositories/workspace_repository.go new file mode 100644 index 0000000..ff88095 --- /dev/null +++ b/backend/internal/features/workspaces/repositories/workspace_repository.go @@ -0,0 +1,52 @@ +package workspaces_repositories + +import ( + "time" + + workspaces_models "postgresus-backend/internal/features/workspaces/models" + "postgresus-backend/internal/storage" + + "github.com/google/uuid" +) + +type WorkspaceRepository struct{} + +func (r *WorkspaceRepository) CreateWorkspace(workspace *workspaces_models.Workspace) error { + if workspace.ID == uuid.Nil { + workspace.ID = uuid.New() + } + + if workspace.CreatedAt.IsZero() { + workspace.CreatedAt = time.Now().UTC() + } + + return storage.GetDb().Create(workspace).Error +} + +func (r *WorkspaceRepository) GetWorkspaceByID( + workspaceID uuid.UUID, +) (*workspaces_models.Workspace, error) { + var workspace workspaces_models.Workspace + + if err := storage.GetDb().Where("id = ?", workspaceID).First(&workspace).Error; err != nil { + return nil, err + } + + return &workspace, nil +} + +func (r *WorkspaceRepository) UpdateWorkspace(workspace *workspaces_models.Workspace) error { + return storage.GetDb().Save(workspace).Error +} + +func (r *WorkspaceRepository) DeleteWorkspace(workspaceID uuid.UUID) error { + return storage.GetDb().Delete(&workspaces_models.Workspace{}, workspaceID).Error +} + +func (r *WorkspaceRepository) GetAllWorkspaces() ([]*workspaces_models.Workspace, error) { + var workspaces []*workspaces_models.Workspace + + err := storage.GetDb().Order("created_at DESC").Find(&workspaces).Error + + return workspaces, err +} diff --git a/backend/internal/features/workspaces/services/di.go b/backend/internal/features/workspaces/services/di.go new file mode 100644 index 0000000..e306e5a --- /dev/null +++ b/backend/internal/features/workspaces/services/di.go @@ -0,0 +1,37 @@ +package workspaces_services + +import ( + "postgresus-backend/internal/features/audit_logs" + users_services "postgresus-backend/internal/features/users/services" + workspaces_interfaces "postgresus-backend/internal/features/workspaces/interfaces" + workspaces_repositories "postgresus-backend/internal/features/workspaces/repositories" +) + +var workspaceRepository = &workspaces_repositories.WorkspaceRepository{} +var membershipRepository = &workspaces_repositories.MembershipRepository{} + +var workspaceService = &WorkspaceService{ + workspaceRepository, + membershipRepository, + users_services.GetUserService(), + audit_logs.GetAuditLogService(), + users_services.GetSettingsService(), + []workspaces_interfaces.WorkspaceDeletionListener{}, +} + +var membershipService = &MembershipService{ + membershipRepository, + workspaceRepository, + users_services.GetUserService(), + audit_logs.GetAuditLogService(), + workspaceService, + users_services.GetSettingsService(), +} + +func GetWorkspaceService() *WorkspaceService { + return workspaceService +} + +func GetMembershipService() *MembershipService { + return membershipService +} diff --git a/backend/internal/features/workspaces/services/membership_service.go b/backend/internal/features/workspaces/services/membership_service.go new file mode 100644 index 0000000..a4a86c9 --- /dev/null +++ b/backend/internal/features/workspaces/services/membership_service.go @@ -0,0 +1,329 @@ +package workspaces_services + +import ( + "errors" + "fmt" + + audit_logs "postgresus-backend/internal/features/audit_logs" + users_dto "postgresus-backend/internal/features/users/dto" + users_enums "postgresus-backend/internal/features/users/enums" + users_models "postgresus-backend/internal/features/users/models" + users_services "postgresus-backend/internal/features/users/services" + workspaces_dto "postgresus-backend/internal/features/workspaces/dto" + workspaces_models "postgresus-backend/internal/features/workspaces/models" + workspaces_repositories "postgresus-backend/internal/features/workspaces/repositories" + + "github.com/google/uuid" +) + +type MembershipService struct { + membershipRepository *workspaces_repositories.MembershipRepository + workspaceRepository *workspaces_repositories.WorkspaceRepository + userService *users_services.UserService + auditLogService *audit_logs.AuditLogService + workspaceService *WorkspaceService + settingsService *users_services.SettingsService +} + +func (s *MembershipService) GetMembers( + workspaceID uuid.UUID, + user *users_models.User, +) (*workspaces_dto.GetMembersResponseDTO, error) { + canView, _, err := s.workspaceService.CanUserAccessWorkspace(workspaceID, user) + if err != nil { + return nil, err + } + if !canView { + return nil, errors.New("insufficient permissions to view workspace members") + } + + members, err := s.membershipRepository.GetWorkspaceMembers(workspaceID) + if err != nil { + return nil, fmt.Errorf("failed to get workspace members: %w", err) + } + + membersList := make([]workspaces_dto.WorkspaceMemberResponseDTO, len(members)) + for i, member := range members { + membersList[i] = *member + } + + return &workspaces_dto.GetMembersResponseDTO{ + Members: membersList, + }, nil +} + +func (s *MembershipService) AddMember( + workspaceID uuid.UUID, + request *workspaces_dto.AddMemberRequestDTO, + addedBy *users_models.User, +) (*workspaces_dto.AddMemberResponseDTO, error) { + if err := s.validateCanManageMembership(workspaceID, addedBy, request.Role); err != nil { + return nil, err + } + + targetUser, err := s.userService.GetUserByEmail(request.Email) + if err != nil { + return nil, err + } + + if targetUser == nil { + // User doesn't exist, invite them + settings, err := s.settingsService.GetSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + + if !addedBy.CanInviteUsers(settings) { + return nil, errors.New("insufficient permissions to invite users") + } + + inviteRequest := &users_dto.InviteUserRequestDTO{ + Email: request.Email, + IntendedWorkspaceID: &workspaceID, + IntendedWorkspaceRole: &request.Role, + } + + inviteResponse, err := s.userService.InviteUser(inviteRequest, addedBy) + if err != nil { + return nil, err + } + + membership := &workspaces_models.WorkspaceMembership{ + UserID: inviteResponse.ID, + WorkspaceID: workspaceID, + Role: request.Role, + } + + if err := s.membershipRepository.CreateMembership(membership); err != nil { + return nil, fmt.Errorf("failed to add member: %w", err) + } + + s.auditLogService.WriteAuditLog( + fmt.Sprintf( + "User invited to workspace: %s and added as %s", + request.Email, + request.Role, + ), + &addedBy.ID, + &workspaceID, + ) + + return &workspaces_dto.AddMemberResponseDTO{ + Status: workspaces_dto.AddStatusInvited, + }, nil + } + + existingMembership, _ := s.membershipRepository.GetMembershipByUserAndWorkspace( + targetUser.ID, + workspaceID, + ) + if existingMembership != nil { + return nil, errors.New("user is already a member of this workspace") + } + + membership := &workspaces_models.WorkspaceMembership{ + UserID: targetUser.ID, + WorkspaceID: workspaceID, + Role: request.Role, + } + + if err := s.membershipRepository.CreateMembership(membership); err != nil { + return nil, fmt.Errorf("failed to add member: %w", err) + } + + s.auditLogService.WriteAuditLog( + fmt.Sprintf("User added to workspace: %s as %s", targetUser.Email, request.Role), + &addedBy.ID, + &workspaceID, + ) + + return &workspaces_dto.AddMemberResponseDTO{ + Status: workspaces_dto.AddStatusAdded, + }, nil +} + +func (s *MembershipService) ChangeMemberRole( + workspaceID uuid.UUID, + memberUserID uuid.UUID, + request *workspaces_dto.ChangeMemberRoleRequestDTO, + changedBy *users_models.User, +) error { + if err := s.validateCanManageMembership(workspaceID, changedBy, request.Role); err != nil { + return err + } + + if memberUserID == changedBy.ID { + return errors.New("cannot change your own role") + } + + existingMembership, err := s.membershipRepository.GetMembershipByUserAndWorkspace( + memberUserID, + workspaceID, + ) + if err != nil { + return errors.New("user is not a member of this workspace") + } + + if existingMembership.Role == users_enums.WorkspaceRoleOwner { + return errors.New("cannot change owner role") + } + + targetUser, err := s.userService.GetUserByID(memberUserID) + if err != nil { + return errors.New("user not found") + } + + if err := s.membershipRepository.UpdateMemberRole(memberUserID, workspaceID, request.Role); err != nil { + return fmt.Errorf("failed to update member role: %w", err) + } + + s.auditLogService.WriteAuditLog( + fmt.Sprintf( + "Member role changed: %s from %s to %s", + targetUser.Email, + existingMembership.Role, + request.Role, + ), + &changedBy.ID, + &workspaceID, + ) + + return nil +} + +func (s *MembershipService) RemoveMember( + workspaceID uuid.UUID, + memberUserID uuid.UUID, + removedBy *users_models.User, +) error { + canManage, err := s.workspaceService.CanUserManageMembership(workspaceID, removedBy) + if err != nil { + return err + } + + if !canManage { + return errors.New("insufficient permissions to remove members") + } + + existingMembership, err := s.membershipRepository.GetMembershipByUserAndWorkspace( + memberUserID, + workspaceID, + ) + if err != nil { + return errors.New("user is not a member of this workspace") + } + + if existingMembership.Role == users_enums.WorkspaceRoleOwner { + return errors.New("cannot remove workspace owner, transfer ownership first") + } + + if existingMembership.Role == users_enums.WorkspaceRoleAdmin { + canManageAdmins, err := s.workspaceService.CanUserManageAdmins(workspaceID, removedBy) + if err != nil { + return err + } + if !canManageAdmins { + return errors.New("only workspace owner can remove admins") + } + } + + targetUser, err := s.userService.GetUserByID(memberUserID) + if err != nil { + return errors.New("user not found") + } + + if err := s.membershipRepository.RemoveMember(memberUserID, workspaceID); err != nil { + return fmt.Errorf("failed to remove member: %w", err) + } + + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Member removed from workspace: %s", targetUser.Email), + &removedBy.ID, + &workspaceID, + ) + + return nil +} + +func (s *MembershipService) TransferOwnership( + workspaceID uuid.UUID, + request *workspaces_dto.TransferOwnershipRequestDTO, + user *users_models.User, +) error { + currentRole, err := s.membershipRepository.GetUserWorkspaceRole(workspaceID, user.ID) + if err != nil { + return fmt.Errorf("failed to get current user role: %w", err) + } + + if user.Role != users_enums.UserRoleAdmin && + (currentRole == nil || *currentRole != users_enums.WorkspaceRoleOwner) { + return errors.New("only workspace owner or admin can transfer ownership") + } + + newOwner, err := s.userService.GetUserByEmail(request.NewOwnerEmail) + if err != nil { + return errors.New("new owner not found") + } + + if newOwner == nil { + return errors.New("new owner not found") + } + + _, err = s.membershipRepository.GetMembershipByUserAndWorkspace(newOwner.ID, workspaceID) + if err != nil { + return errors.New("new owner must be a workspace member") + } + + currentOwner, err := s.membershipRepository.GetWorkspaceOwner(workspaceID) + if err != nil { + return fmt.Errorf("failed to find current workspace owner: %w", err) + } + + if currentOwner == nil { + return errors.New("no current workspace owner found") + } + + if err := s.membershipRepository.UpdateMemberRole(newOwner.ID, workspaceID, users_enums.WorkspaceRoleOwner); err != nil { + return fmt.Errorf("failed to update new owner role: %w", err) + } + + if err := s.membershipRepository.UpdateMemberRole(currentOwner.UserID, workspaceID, users_enums.WorkspaceRoleAdmin); err != nil { + return fmt.Errorf("failed to update previous owner role: %w", err) + } + + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Workspace ownership transferred to: %s", newOwner.Email), + &user.ID, + &workspaceID, + ) + + return nil +} + +func (s *MembershipService) validateCanManageMembership( + workspaceID uuid.UUID, + user *users_models.User, + changesRoleTo users_enums.WorkspaceRole, +) error { + if changesRoleTo == users_enums.WorkspaceRoleAdmin { + canManageAdmins, err := s.workspaceService.CanUserManageAdmins(workspaceID, user) + if err != nil { + return err + } + if !canManageAdmins { + return errors.New("only workspace owner can add/manage admins") + } + return nil + } + + canManageMembership, err := s.workspaceService.CanUserManageMembership(workspaceID, user) + if err != nil { + return err + } + + if !canManageMembership { + return errors.New("insufficient permissions to manage members") + } + + return nil +} diff --git a/backend/internal/features/workspaces/services/workspace_service.go b/backend/internal/features/workspaces/services/workspace_service.go new file mode 100644 index 0000000..5afd8aa --- /dev/null +++ b/backend/internal/features/workspaces/services/workspace_service.go @@ -0,0 +1,316 @@ +package workspaces_services + +import ( + "errors" + "fmt" + "time" + + audit_logs "postgresus-backend/internal/features/audit_logs" + users_enums "postgresus-backend/internal/features/users/enums" + users_models "postgresus-backend/internal/features/users/models" + users_services "postgresus-backend/internal/features/users/services" + workspaces_dto "postgresus-backend/internal/features/workspaces/dto" + workspaces_interfaces "postgresus-backend/internal/features/workspaces/interfaces" + workspaces_models "postgresus-backend/internal/features/workspaces/models" + workspaces_repositories "postgresus-backend/internal/features/workspaces/repositories" + + "github.com/google/uuid" +) + +type WorkspaceService struct { + workspaceRepository *workspaces_repositories.WorkspaceRepository + membershipRepository *workspaces_repositories.MembershipRepository + userService *users_services.UserService + auditLogService *audit_logs.AuditLogService + settingsService *users_services.SettingsService + workspaceDeletionListeners []workspaces_interfaces.WorkspaceDeletionListener +} + +func (s *WorkspaceService) AddWorkspaceDeletionListener( + listener workspaces_interfaces.WorkspaceDeletionListener, +) { + s.workspaceDeletionListeners = append(s.workspaceDeletionListeners, listener) +} + +func (s *WorkspaceService) CreateWorkspace( + request *workspaces_dto.CreateWorkspaceRequestDTO, + creator *users_models.User, +) (*workspaces_dto.WorkspaceResponseDTO, error) { + settings, err := s.settingsService.GetSettings() + + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + + if !creator.CanCreateWorkspaces(settings) { + return nil, errors.New("insufficient permissions to create workspaces") + } + + workspace := &workspaces_models.Workspace{ + ID: uuid.New(), + Name: request.Name, + CreatedAt: time.Now().UTC(), + } + + if err := s.workspaceRepository.CreateWorkspace(workspace); err != nil { + return nil, fmt.Errorf("failed to create workspace: %w", err) + } + + membership := &workspaces_models.WorkspaceMembership{ + UserID: creator.ID, + WorkspaceID: workspace.ID, + Role: users_enums.WorkspaceRoleOwner, + CreatedAt: time.Now().UTC(), + } + + if err := s.membershipRepository.CreateMembership(membership); err != nil { + return nil, fmt.Errorf("failed to create workspace membership: %w", err) + } + + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Workspace created: %s", workspace.Name), + &creator.ID, + &workspace.ID, + ) + + ownerRole := users_enums.WorkspaceRoleOwner + return &workspaces_dto.WorkspaceResponseDTO{ + ID: workspace.ID, + Name: workspace.Name, + CreatedAt: workspace.CreatedAt, + UserRole: &ownerRole, + }, nil +} + +func (s *WorkspaceService) GetWorkspace( + workspaceID uuid.UUID, + user *users_models.User, +) (*workspaces_models.Workspace, error) { + canView, _, err := s.CanUserAccessWorkspace(workspaceID, user) + if err != nil { + return nil, err + } + if !canView { + return nil, errors.New("insufficient permissions to view workspace") + } + + return s.workspaceRepository.GetWorkspaceByID(workspaceID) +} + +func (s *WorkspaceService) GetUserWorkspaces( + user *users_models.User, +) (*workspaces_dto.ListWorkspacesResponseDTO, error) { + workspaces, err := s.membershipRepository.GetWorkspacesWithRolesByUserID(user.Role, user.ID) + if err != nil { + return nil, fmt.Errorf("failed to get user workspaces: %w", err) + } + + return &workspaces_dto.ListWorkspacesResponseDTO{ + Workspaces: workspaces, + }, nil +} + +func (s *WorkspaceService) UpdateWorkspace( + workspaceID uuid.UUID, + updateDTO *workspaces_models.Workspace, + user *users_models.User, +) (*workspaces_models.Workspace, error) { + canManage, err := s.CanUserManageWorkspace(workspaceID, user) + + if err != nil { + return nil, err + } + if !canManage { + return nil, errors.New("insufficient permissions to update workspace") + } + + existingWorkspace, err := s.workspaceRepository.GetWorkspaceByID(workspaceID) + if err != nil { + return nil, fmt.Errorf("failed to get workspace: %w", err) + } + + updateDTO.ID = workspaceID + updateDTO.CreatedAt = existingWorkspace.CreatedAt + + existingWorkspace.UpdateFromDTO(updateDTO) + + if err := s.workspaceRepository.UpdateWorkspace(existingWorkspace); err != nil { + return nil, fmt.Errorf("failed to update workspace: %w", err) + } + + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Workspace updated: %s", updateDTO.Name), + &user.ID, + &workspaceID, + ) + + return existingWorkspace, nil +} + +func (s *WorkspaceService) DeleteWorkspace(workspaceID uuid.UUID, user *users_models.User) error { + if user.Role != users_enums.UserRoleAdmin { + userWorkspaceRole, err := s.GetUserWorkspaceRole(workspaceID, user.ID) + if err != nil { + return fmt.Errorf("failed to get user role: %w", err) + } + + if userWorkspaceRole == nil || *userWorkspaceRole != users_enums.WorkspaceRoleOwner { + return errors.New("only workspace owner or admin can delete workspace") + } + } + + workspace, err := s.workspaceRepository.GetWorkspaceByID(workspaceID) + if err != nil { + return fmt.Errorf("failed to get workspace: %w", err) + } + + for _, listener := range s.workspaceDeletionListeners { + if err := listener.OnBeforeWorkspaceDeletion(workspaceID); err != nil { + return fmt.Errorf("failed to delete workspace: %w", err) + } + } + + if err := s.workspaceRepository.DeleteWorkspace(workspaceID); err != nil { + return fmt.Errorf("failed to delete workspace: %w", err) + } + + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Workspace deleted: %s", workspace.Name), + &user.ID, + &workspaceID, + ) + + return nil +} + +func (s *WorkspaceService) GetUserWorkspaceRole( + workspaceID uuid.UUID, + userID uuid.UUID, +) (*users_enums.WorkspaceRole, error) { + return s.membershipRepository.GetUserWorkspaceRole(workspaceID, userID) +} + +func (s *WorkspaceService) CanUserAccessWorkspace( + workspaceID uuid.UUID, + user *users_models.User, +) (bool, *users_enums.WorkspaceRole, error) { + if user.Role == users_enums.UserRoleAdmin { + adminRole := users_enums.WorkspaceRoleOwner + return true, &adminRole, nil + } + + role, err := s.membershipRepository.GetUserWorkspaceRole(workspaceID, user.ID) + if err != nil { + return false, nil, nil + } + + return role != nil, role, nil +} + +func (s *WorkspaceService) CanUserManageWorkspace( + workspaceID uuid.UUID, + user *users_models.User, +) (bool, error) { + if user.Role == users_enums.UserRoleAdmin { + return true, nil + } + + role, err := s.membershipRepository.GetUserWorkspaceRole(workspaceID, user.ID) + if err != nil { + return false, err + } + + if role == nil { + return false, nil + } + + return *role == users_enums.WorkspaceRoleOwner || + *role == users_enums.WorkspaceRoleAdmin, nil +} + +func (s *WorkspaceService) CanUserManageDBs( + workspaceID uuid.UUID, + user *users_models.User, +) (bool, error) { + if user.Role == users_enums.UserRoleAdmin { + return true, nil + } + + role, err := s.membershipRepository.GetUserWorkspaceRole(workspaceID, user.ID) + if err != nil { + return false, err + } + + if role == nil { + return false, nil + } + + return *role == users_enums.WorkspaceRoleOwner || + *role == users_enums.WorkspaceRoleAdmin || *role == users_enums.WorkspaceRoleMember, nil +} + +func (s *WorkspaceService) CanUserManageMembership( + workspaceID uuid.UUID, + user *users_models.User, +) (bool, error) { + if user.Role == users_enums.UserRoleAdmin { + return true, nil + } + + role, err := s.membershipRepository.GetUserWorkspaceRole(workspaceID, user.ID) + if err != nil { + return false, err + } + + if role == nil { + return false, nil + } + + return *role == users_enums.WorkspaceRoleOwner || *role == users_enums.WorkspaceRoleAdmin, nil +} + +func (s *WorkspaceService) CanUserManageAdmins( + workspaceID uuid.UUID, + user *users_models.User, +) (bool, error) { + if user.Role == users_enums.UserRoleAdmin { + return true, nil + } + + role, err := s.membershipRepository.GetUserWorkspaceRole(workspaceID, user.ID) + if err != nil { + return false, err + } + + if role == nil { + return false, nil + } + + return *role == users_enums.WorkspaceRoleOwner, nil +} + +func (s *WorkspaceService) GetWorkspaceAuditLogs( + workspaceID uuid.UUID, + user *users_models.User, + request *audit_logs.GetAuditLogsRequest, +) (*audit_logs.GetAuditLogsResponse, error) { + canView, _, err := s.CanUserAccessWorkspace(workspaceID, user) + if err != nil { + return nil, err + } + if !canView { + return nil, errors.New("insufficient permissions to view workspace audit logs") + } + + return s.auditLogService.GetWorkspaceAuditLogs(workspaceID, request) +} + +func (s *WorkspaceService) GetAllWorkspaces() ([]*workspaces_models.Workspace, error) { + return s.workspaceRepository.GetAllWorkspaces() +} + +func (s *WorkspaceService) GetWorkspaceByIDInternal( + workspaceID uuid.UUID, +) (*workspaces_models.Workspace, error) { + return s.workspaceRepository.GetWorkspaceByID(workspaceID) +} diff --git a/backend/internal/features/workspaces/testing/interfaces.go b/backend/internal/features/workspaces/testing/interfaces.go new file mode 100644 index 0000000..4942226 --- /dev/null +++ b/backend/internal/features/workspaces/testing/interfaces.go @@ -0,0 +1,7 @@ +package workspaces_testing + +import "github.com/gin-gonic/gin" + +type ControllerInterface interface { + RegisterRoutes(router *gin.RouterGroup) +} diff --git a/backend/internal/features/workspaces/testing/testing.go b/backend/internal/features/workspaces/testing/testing.go new file mode 100644 index 0000000..39ab325 --- /dev/null +++ b/backend/internal/features/workspaces/testing/testing.go @@ -0,0 +1,477 @@ +package workspaces_testing + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "postgresus-backend/internal/features/audit_logs" + users_dto "postgresus-backend/internal/features/users/dto" + users_enums "postgresus-backend/internal/features/users/enums" + users_middleware "postgresus-backend/internal/features/users/middleware" + users_services "postgresus-backend/internal/features/users/services" + users_testing "postgresus-backend/internal/features/users/testing" + workspaces_dto "postgresus-backend/internal/features/workspaces/dto" + workspaces_models "postgresus-backend/internal/features/workspaces/models" + workspaces_repositories "postgresus-backend/internal/features/workspaces/repositories" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func CreateTestRouter(controllers ...ControllerInterface) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + + v1 := router.Group("/api/v1") + protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService())) + + for _, controller := range controllers { + if routerGroup, ok := protected.(*gin.RouterGroup); ok { + controller.RegisterRoutes(routerGroup) + } + } + + audit_logs.SetupDependencies() + + return router +} + +func CreateTestWorkspace( + name string, + owner *users_dto.SignInResponseDTO, + router *gin.Engine, +) *workspaces_models.Workspace { + workspace, _ := CreateTestWorkspaceViaAPI(name, owner, router) + return workspace +} + +func CreateTestWorkspaceViaAPI( + name string, + owner *users_dto.SignInResponseDTO, + router *gin.Engine, +) (*workspaces_models.Workspace, string) { + return createTestWorkspaceViaAPI(name, owner, router, true) +} + +func CreateTestWorkspaceViaAPIWithoutSettingsChange( + name string, + owner *users_dto.SignInResponseDTO, + router *gin.Engine, +) (*workspaces_models.Workspace, string) { + return createTestWorkspaceViaAPI(name, owner, router, false) +} + +func createTestWorkspaceViaAPI( + name string, + owner *users_dto.SignInResponseDTO, + router *gin.Engine, + enableMemberCreation bool, +) (*workspaces_models.Workspace, string) { + if enableMemberCreation { + users_testing.EnableMemberWorkspaceCreation() + defer users_testing.ResetSettingsToDefaults() + } + + request := workspaces_dto.CreateWorkspaceRequestDTO{Name: name} + w := MakeAPIRequest(router, "POST", "/api/v1/workspaces", "Bearer "+owner.Token, request) + + if w.Code != http.StatusOK { + panic( + fmt.Sprintf( + "Failed to create workspace. Status: %d, Body: %s", + w.Code, + w.Body.String(), + ), + ) + } + + var response workspaces_dto.WorkspaceResponseDTO + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + panic(err) + } + + workspace := &workspaces_models.Workspace{ + ID: response.ID, + Name: response.Name, + } + + return workspace, owner.Token +} + +func CreateTestWorkspaceWithToken( + name string, + token string, + router *gin.Engine, +) (*workspaces_models.Workspace, string) { + return createTestWorkspaceWithToken(name, token, router, true) +} + +func CreateTestWorkspaceWithTokenWithoutSettingsChange( + name string, + token string, + router *gin.Engine, +) (*workspaces_models.Workspace, string) { + return createTestWorkspaceWithToken(name, token, router, false) +} + +func createTestWorkspaceWithToken( + name string, + token string, + router *gin.Engine, + enableMemberCreation bool, +) (*workspaces_models.Workspace, string) { + if enableMemberCreation { + users_testing.EnableMemberWorkspaceCreation() + defer users_testing.ResetSettingsToDefaults() + } + + request := workspaces_dto.CreateWorkspaceRequestDTO{Name: name} + w := MakeAPIRequest(router, "POST", "/api/v1/workspaces", "Bearer "+token, request) + + if w.Code != http.StatusOK { + panic( + fmt.Sprintf( + "Failed to create workspace. Status: %d, Body: %s", + w.Code, + w.Body.String(), + ), + ) + } + + var response workspaces_dto.WorkspaceResponseDTO + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + panic(err) + } + + workspace := &workspaces_models.Workspace{ + ID: response.ID, + Name: response.Name, + } + + return workspace, token +} + +func AddMemberToWorkspace( + workspace *workspaces_models.Workspace, + member *users_dto.SignInResponseDTO, + role users_enums.WorkspaceRole, + ownerToken string, + router *gin.Engine, +) { + request := workspaces_dto.AddMemberRequestDTO{ + Email: member.Email, + Role: role, + } + + w := MakeAPIRequest( + router, + "POST", + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+ownerToken, + request, + ) + + if w.Code != http.StatusOK { + panic("Failed to add member to workspace via API: " + w.Body.String()) + } +} + +func AddMemberToWorkspaceViaOwner( + workspace *workspaces_models.Workspace, + member *users_dto.SignInResponseDTO, + role users_enums.WorkspaceRole, + router *gin.Engine, +) { + membershipRepo := &workspaces_repositories.MembershipRepository{} + workspaceMembers, err := membershipRepo.GetWorkspaceMembers(workspace.ID) + if err != nil { + panic("Failed to get workspace members: " + err.Error()) + } + + var ownerToken string + for _, m := range workspaceMembers { + if m.Role == users_enums.WorkspaceRoleOwner { + userService := users_services.GetUserService() + + owner, err := userService.GetUserByID(m.UserID) + if err != nil { + panic("Failed to get owner user: " + err.Error()) + } + + tokenResponse, err := userService.GenerateAccessToken(owner) + if err != nil { + panic("Failed to generate owner token: " + err.Error()) + } + + ownerToken = tokenResponse.Token + + break + } + } + + if ownerToken == "" { + panic("No workspace owner found") + } + + AddMemberToWorkspace(workspace, member, role, ownerToken, router) +} + +func InviteMemberToWorkspace( + workspace *workspaces_models.Workspace, + email string, + role users_enums.WorkspaceRole, + inviterToken string, + router *gin.Engine, +) *workspaces_dto.AddMemberResponseDTO { + request := workspaces_dto.AddMemberRequestDTO{ + Email: email, + Role: role, + } + + w := MakeAPIRequest( + router, + "POST", + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+inviterToken, + request, + ) + + if w.Code != http.StatusOK { + panic("Failed to invite member to workspace via API: " + w.Body.String()) + } + + var response workspaces_dto.AddMemberResponseDTO + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + panic(err) + } + + return &response +} + +func ChangeMemberRole( + workspace *workspaces_models.Workspace, + memberUserID uuid.UUID, + newRole users_enums.WorkspaceRole, + changerToken string, + router *gin.Engine, +) { + request := workspaces_dto.ChangeMemberRoleRequestDTO{ + Role: newRole, + } + + w := MakeAPIRequest( + router, + "PUT", + fmt.Sprintf( + "/api/v1/workspaces/memberships/%s/members/%s/role", + workspace.ID.String(), + memberUserID.String(), + ), + "Bearer "+changerToken, + request, + ) + + if w.Code != http.StatusOK { + panic("Failed to change member role via API: " + w.Body.String()) + } +} + +func RemoveMemberFromWorkspace( + workspace *workspaces_models.Workspace, + memberUserID uuid.UUID, + removerToken string, + router *gin.Engine, +) { + w := MakeAPIRequest( + router, + "DELETE", + fmt.Sprintf( + "/api/v1/workspaces/memberships/%s/members/%s", + workspace.ID.String(), + memberUserID.String(), + ), + "Bearer "+removerToken, + nil, + ) + + if w.Code != http.StatusOK { + panic("Failed to remove member from workspace via API: " + w.Body.String()) + } +} + +func GetWorkspaceMembers( + workspace *workspaces_models.Workspace, + requesterToken string, + router *gin.Engine, +) *workspaces_dto.GetMembersResponseDTO { + w := MakeAPIRequest( + router, + "GET", + "/api/v1/workspaces/memberships/"+workspace.ID.String()+"/members", + "Bearer "+requesterToken, + nil, + ) + + if w.Code != http.StatusOK { + panic("Failed to get workspace members via API: " + w.Body.String()) + } + + var response workspaces_dto.GetMembersResponseDTO + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + panic(err) + } + + return &response +} + +func UpdateWorkspace( + workspace *workspaces_models.Workspace, + updateData *workspaces_models.Workspace, + updaterToken string, + router *gin.Engine, +) *workspaces_models.Workspace { + w := MakeAPIRequest( + router, + "PUT", + "/api/v1/workspaces/"+workspace.ID.String(), + "Bearer "+updaterToken, + updateData, + ) + + if w.Code != http.StatusOK { + panic("Failed to update workspace via API: " + w.Body.String()) + } + + var response workspaces_models.Workspace + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + panic(err) + } + + return &response +} + +func DeleteWorkspace( + workspace *workspaces_models.Workspace, + deleterToken string, + router *gin.Engine, +) { + w := MakeAPIRequest( + router, + "DELETE", + "/api/v1/workspaces/"+workspace.ID.String(), + "Bearer "+deleterToken, + nil, + ) + + if w.Code != http.StatusOK { + panic("Failed to delete workspace via API: " + w.Body.String()) + } +} + +func RemoveTestWorkspace(workspace *workspaces_models.Workspace, router *gin.Engine) { + membershipRepo := &workspaces_repositories.MembershipRepository{} + workspaceMembers, err := membershipRepo.GetWorkspaceMembers(workspace.ID) + if err != nil { + panic("Failed to get workspace members: " + err.Error()) + } + + var ownerToken string + for _, m := range workspaceMembers { + if m.Role == users_enums.WorkspaceRoleOwner { + userService := users_services.GetUserService() + + owner, err := userService.GetUserByID(m.UserID) + if err != nil { + panic("Failed to get owner user: " + err.Error()) + } + + tokenResponse, err := userService.GenerateAccessToken(owner) + if err != nil { + panic("Failed to generate owner token: " + err.Error()) + } + + ownerToken = tokenResponse.Token + break + } + } + + if ownerToken == "" { + panic("No workspace owner found") + } + + DeleteWorkspace(workspace, ownerToken, router) +} + +func MakeAPIRequest( + router *gin.Engine, + method, url, authToken string, + body any, +) *httptest.ResponseRecorder { + var requestBody *bytes.Buffer + if body != nil { + bodyJSON, err := json.Marshal(body) + if err != nil { + panic(err) + } + requestBody = bytes.NewBuffer(bodyJSON) + } else { + requestBody = bytes.NewBuffer(nil) + } + + req, err := http.NewRequest(method, url, requestBody) + if err != nil { + panic(err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if authToken != "" { + req.Header.Set("Authorization", authToken) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +// CreateTestWorkspaceDirect creates a workspace directly without using HTTP API +func CreateTestWorkspaceDirect( + name string, + ownerID uuid.UUID, +) (*workspaces_models.Workspace, error) { + repo := &workspaces_repositories.WorkspaceRepository{} + membershipRepo := &workspaces_repositories.MembershipRepository{} + + workspace := &workspaces_models.Workspace{ + Name: name, + } + + err := repo.CreateWorkspace(workspace) + if err != nil { + return nil, err + } + + // Create owner membership + membership := &workspaces_models.WorkspaceMembership{ + WorkspaceID: workspace.ID, + UserID: ownerID, + Role: users_enums.WorkspaceRoleOwner, + } + + err = membershipRepo.CreateMembership(membership) + if err != nil { + return nil, err + } + + return workspace, nil +} + +// RemoveTestWorkspaceDirect removes a workspace directly without using HTTP API +func RemoveTestWorkspaceDirect(workspaceID uuid.UUID) error { + repo := &workspaces_repositories.WorkspaceRepository{} + return repo.DeleteWorkspace(workspaceID) +} diff --git a/backend/migrations/20251031143019_add_many_users_and_settings.sql b/backend/migrations/20251031143019_add_many_users_and_settings.sql new file mode 100644 index 0000000..09bf4cb --- /dev/null +++ b/backend/migrations/20251031143019_add_many_users_and_settings.sql @@ -0,0 +1,66 @@ +-- +goose Up +-- +goose StatementBegin + +-- Create users_settings table +CREATE TABLE users_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + is_allow_external_registrations BOOLEAN NOT NULL DEFAULT TRUE, + is_allow_member_invitations BOOLEAN NOT NULL DEFAULT TRUE, + is_member_allowed_to_create_workspaces BOOLEAN NOT NULL DEFAULT TRUE +); + +-- Add new columns to users table +ALTER TABLE users ADD COLUMN name TEXT; +ALTER TABLE users ADD COLUMN status TEXT; +ALTER TABLE users ADD COLUMN github_oauth_id TEXT; +ALTER TABLE users ADD COLUMN google_oauth_id TEXT; + +-- Update existing user(s) +-- Set email to 'admin', name to 'Admin', and status to 'ACTIVE' +UPDATE users +SET + email = 'admin', + name = 'Admin', + status = 'ACTIVE' +WHERE id IN (SELECT id FROM users LIMIT 1); + +-- Remove all users except the admin user (should not exist, just to be sure) +DELETE FROM users +WHERE id NOT IN (SELECT id FROM users WHERE email = 'admin' LIMIT 1); + +-- Make name and status NOT NULL after data migration +ALTER TABLE users ALTER COLUMN name SET NOT NULL; +ALTER TABLE users ALTER COLUMN status SET NOT NULL; + +-- Make hashed_password nullable to support OAuth-only accounts +ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL; + + +-- Create indexes for new columns +CREATE INDEX idx_users_status ON users (status); +CREATE UNIQUE INDEX idx_users_github_oauth_id ON users (github_oauth_id) WHERE github_oauth_id IS NOT NULL; +CREATE UNIQUE INDEX idx_users_google_oauth_id ON users (google_oauth_id) WHERE google_oauth_id IS NOT NULL; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Drop indexes +DROP INDEX IF EXISTS idx_users_google_oauth_id; +DROP INDEX IF EXISTS idx_users_github_oauth_id; +DROP INDEX IF EXISTS idx_users_status; + +-- Remove new columns from users table +ALTER TABLE users DROP COLUMN IF EXISTS google_oauth_id; +ALTER TABLE users DROP COLUMN IF EXISTS github_oauth_id; +ALTER TABLE users DROP COLUMN IF EXISTS status; +ALTER TABLE users DROP COLUMN IF EXISTS name; + +-- Restore hashed_password NOT NULL constraint +ALTER TABLE users ALTER COLUMN hashed_password SET NOT NULL; + +-- Drop new tables +DROP TABLE IF EXISTS users_settings; + +-- +goose StatementEnd diff --git a/backend/migrations/20251031145001_add_system_audit_logs.sql b/backend/migrations/20251031145001_add_system_audit_logs.sql new file mode 100644 index 0000000..fbd393b --- /dev/null +++ b/backend/migrations/20251031145001_add_system_audit_logs.sql @@ -0,0 +1,42 @@ +-- +goose Up +-- +goose StatementBegin + +-- Create audit_logs table +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID, + workspace_id UUID, + message TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Add foreign key constraints +ALTER TABLE audit_logs + ADD CONSTRAINT fk_audit_logs_user_id + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE SET NULL; + +-- Create indexes for efficient querying +CREATE INDEX idx_audit_logs_user_id ON audit_logs (user_id); +CREATE INDEX idx_audit_logs_workspace_id ON audit_logs (workspace_id); +CREATE INDEX idx_audit_logs_created_at ON audit_logs (created_at); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Drop indexes +DROP INDEX IF EXISTS idx_audit_logs_created_at; +DROP INDEX IF EXISTS idx_audit_logs_workspace_id; +DROP INDEX IF EXISTS idx_audit_logs_user_id; + +-- Drop foreign key constraints +ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS fk_audit_logs_workspace_id; +ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS fk_audit_logs_user_id; + +-- Drop table +DROP TABLE IF EXISTS audit_logs; + +-- +goose StatementEnd diff --git a/backend/migrations/20251031155004_add_workspaces.sql b/backend/migrations/20251031155004_add_workspaces.sql new file mode 100644 index 0000000..aa329df --- /dev/null +++ b/backend/migrations/20251031155004_add_workspaces.sql @@ -0,0 +1,59 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE workspaces ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE workspace_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + workspace_id UUID NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE workspace_memberships + ADD CONSTRAINT fk_workspace_memberships_user_id + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE; + +ALTER TABLE workspace_memberships + ADD CONSTRAINT fk_workspace_memberships_workspace_id + FOREIGN KEY (workspace_id) + REFERENCES workspaces (id) + ON DELETE CASCADE; + +ALTER TABLE workspace_memberships + ADD CONSTRAINT uk_workspace_memberships_user_workspace + UNIQUE (user_id, workspace_id); + +CREATE INDEX idx_workspaces_created_at ON workspaces (created_at DESC); + +CREATE INDEX idx_workspace_memberships_user_id ON workspace_memberships (user_id); +CREATE INDEX idx_workspace_memberships_workspace_id ON workspace_memberships (workspace_id); +CREATE INDEX idx_workspace_memberships_user_workspace ON workspace_memberships (user_id, workspace_id); +CREATE INDEX idx_workspace_memberships_workspace_user ON workspace_memberships (workspace_id, user_id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP INDEX IF EXISTS idx_workspace_memberships_workspace_user; +DROP INDEX IF EXISTS idx_workspace_memberships_user_workspace; +DROP INDEX IF EXISTS idx_workspace_memberships_workspace_id; +DROP INDEX IF EXISTS idx_workspace_memberships_user_id; + +DROP INDEX IF EXISTS idx_workspaces_created_at; + +ALTER TABLE workspace_memberships DROP CONSTRAINT IF EXISTS fk_workspace_memberships_workspace_id; +ALTER TABLE workspace_memberships DROP CONSTRAINT IF EXISTS fk_workspace_memberships_user_id; + +DROP TABLE IF EXISTS workspace_memberships; +DROP TABLE IF EXISTS workspaces; + +-- +goose StatementEnd diff --git a/backend/migrations/20251031155005_connect_workspaces_with_dbs.sql b/backend/migrations/20251031155005_connect_workspaces_with_dbs.sql new file mode 100644 index 0000000..1f08a2a --- /dev/null +++ b/backend/migrations/20251031155005_connect_workspaces_with_dbs.sql @@ -0,0 +1,56 @@ +-- +goose Up +-- +goose StatementBegin + +-- Add workspace_id column to databases table +ALTER TABLE databases ADD COLUMN workspace_id UUID; + +-- Create default workspace only if there are databases, storages, or notifiers +INSERT INTO workspaces (id, name, created_at) +SELECT + gen_random_uuid(), + 'Default Workspace', + NOW() +WHERE NOT EXISTS (SELECT 1 FROM workspaces) + AND ( + EXISTS (SELECT 1 FROM databases WHERE user_id IS NOT NULL) + OR EXISTS (SELECT 1 FROM storages) + OR EXISTS (SELECT 1 FROM notifiers) + ); + +-- Update databases that HAVE user_id to get workspace_id +-- Databases without user_id (restore databases) remain with workspace_id = null +UPDATE databases +SET workspace_id = (SELECT id FROM workspaces ORDER BY created_at ASC LIMIT 1) +WHERE workspace_id IS NULL AND user_id IS NOT NULL; + +-- Add foreign key constraint +ALTER TABLE databases + ADD CONSTRAINT fk_databases_workspace_id + FOREIGN KEY (workspace_id) + REFERENCES workspaces (id); + +-- Add index for workspace_id lookups +CREATE INDEX idx_databases_workspace_id ON databases (workspace_id); + +-- Now drop the user_id column +ALTER TABLE databases DROP COLUMN user_id; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Restore user_id column (cannot restore original values) +ALTER TABLE databases ADD COLUMN user_id UUID; + +-- Drop index +DROP INDEX IF EXISTS idx_databases_workspace_id; + +-- Drop foreign key constraint +ALTER TABLE databases DROP CONSTRAINT IF EXISTS fk_databases_workspace_id; + +-- Drop workspace_id column +ALTER TABLE databases DROP COLUMN workspace_id; + +-- +goose StatementEnd + diff --git a/backend/migrations/20251104000000_workspace_scope_storages_notifiers.sql b/backend/migrations/20251104000000_workspace_scope_storages_notifiers.sql new file mode 100644 index 0000000..84fc476 --- /dev/null +++ b/backend/migrations/20251104000000_workspace_scope_storages_notifiers.sql @@ -0,0 +1,68 @@ +-- +goose Up +-- +goose StatementBegin + +-- Add workspace_id column to storages table +ALTER TABLE storages ADD COLUMN workspace_id UUID; + +-- Add workspace_id column to notifiers table +ALTER TABLE notifiers ADD COLUMN workspace_id UUID; + +-- Update storages to get workspace_id from first workspace +UPDATE storages +SET workspace_id = (SELECT id FROM workspaces ORDER BY created_at ASC LIMIT 1) +WHERE workspace_id IS NULL; + +-- Update notifiers to get workspace_id from first workspace +UPDATE notifiers +SET workspace_id = (SELECT id FROM workspaces ORDER BY created_at ASC LIMIT 1) +WHERE workspace_id IS NULL; + +-- Add foreign key constraint for storages +ALTER TABLE storages + ADD CONSTRAINT fk_storages_workspace_id + FOREIGN KEY (workspace_id) + REFERENCES workspaces (id); + +-- Add foreign key constraint for notifiers +ALTER TABLE notifiers + ADD CONSTRAINT fk_notifiers_workspace_id + FOREIGN KEY (workspace_id) + REFERENCES workspaces (id); + +-- Add index for workspace_id lookups on storages +CREATE INDEX idx_storages_workspace_id ON storages (workspace_id); + +-- Add index for workspace_id lookups on notifiers +CREATE INDEX idx_notifiers_workspace_id ON notifiers (workspace_id); + +-- Now drop the user_id column from storages +ALTER TABLE storages DROP COLUMN user_id; + +-- Now drop the user_id column from notifiers +ALTER TABLE notifiers DROP COLUMN user_id; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Restore user_id column for storages (cannot restore original values) +ALTER TABLE storages ADD COLUMN user_id UUID; + +-- Restore user_id column for notifiers (cannot restore original values) +ALTER TABLE notifiers ADD COLUMN user_id UUID; + +-- Drop indexes +DROP INDEX IF EXISTS idx_storages_workspace_id; +DROP INDEX IF EXISTS idx_notifiers_workspace_id; + +-- Drop foreign key constraints +ALTER TABLE storages DROP CONSTRAINT IF EXISTS fk_storages_workspace_id; +ALTER TABLE notifiers DROP CONSTRAINT IF EXISTS fk_notifiers_workspace_id; + +-- Drop workspace_id columns +ALTER TABLE storages DROP COLUMN workspace_id; +ALTER TABLE notifiers DROP COLUMN workspace_id; + +-- +goose StatementEnd + diff --git a/contribute/README.md b/contribute/README.md index 786c56a..35f117e 100644 --- a/contribute/README.md +++ b/contribute/README.md @@ -73,35 +73,22 @@ Before taking anything more than a couple of lines of code, please write Rostisl Nearsest features: - add API keys and API actions +- add encryption -Backups flow: - -- check Neon backups flow +Storages tasks: - check AWS S3 support -- when testing connection with S3 - verify files can be really uploaded -- do not remove old backups on backups disable +- check Google Cloud S3 support - add FTP - add Dropbox - add OneDrive - add NAS - add Yandex Drive -- think about pg_dumpall / pg_basebackup / WAL backup / incremental backups -- add encryption for backups -- add support of PgBouncer - -Notifications flow: +Notifications tasks: - add Mattermost +- make webhooks flexible +- add Gotify Extra: -- add HTTPS for Postgresus -- add simple SQL queries via UI -- add support of Kubernetes Helm -- create pretty website like rybbit.io with demo - -Monitoring flow: - -- add queries stats (slowest, most frequent, etc. via pg_stat_statements) -- add DB size distribution chart (tables, indexes, etc.) -- add chart of connections (from IPs, apps names, etc.) +- add HTTPS for Postgresus \ No newline at end of file diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 8033938..07d31ed 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,5 +1,5 @@ - + - + diff --git a/frontend/public/icons/menu/arrow-down-gray.svg b/frontend/public/icons/menu/arrow-down-gray.svg new file mode 100644 index 0000000..5f9f6b5 --- /dev/null +++ b/frontend/public/icons/menu/arrow-down-gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/menu/global-settings-gray.svg b/frontend/public/icons/menu/global-settings-gray.svg new file mode 100644 index 0000000..aa55bb3 --- /dev/null +++ b/frontend/public/icons/menu/global-settings-gray.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/menu/global-settings-white.svg b/frontend/public/icons/menu/global-settings-white.svg new file mode 100644 index 0000000..81b1821 --- /dev/null +++ b/frontend/public/icons/menu/global-settings-white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/menu/profile-gray.svg b/frontend/public/icons/menu/profile-gray.svg new file mode 100644 index 0000000..3be36f3 --- /dev/null +++ b/frontend/public/icons/menu/profile-gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/menu/profile-white.svg b/frontend/public/icons/menu/profile-white.svg new file mode 100644 index 0000000..f196bca --- /dev/null +++ b/frontend/public/icons/menu/profile-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/menu/user-card-gray.svg b/frontend/public/icons/menu/user-card-gray.svg new file mode 100644 index 0000000..e0d510f --- /dev/null +++ b/frontend/public/icons/menu/user-card-gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/menu/user-card-white.svg b/frontend/public/icons/menu/user-card-white.svg new file mode 100644 index 0000000..7794ab0 --- /dev/null +++ b/frontend/public/icons/menu/user-card-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/menu/users-gray.svg b/frontend/public/icons/menu/users-gray.svg new file mode 100644 index 0000000..bf9cb31 --- /dev/null +++ b/frontend/public/icons/menu/users-gray.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/menu/users-white.svg b/frontend/public/icons/menu/users-white.svg new file mode 100644 index 0000000..81f6032 --- /dev/null +++ b/frontend/public/icons/menu/users-white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/menu/workspace-settings-gray.svg b/frontend/public/icons/menu/workspace-settings-gray.svg new file mode 100644 index 0000000..3a2f78e --- /dev/null +++ b/frontend/public/icons/menu/workspace-settings-gray.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/menu/workspace-settings-white.svg b/frontend/public/icons/menu/workspace-settings-white.svg new file mode 100644 index 0000000..3fdf9e0 --- /dev/null +++ b/frontend/public/icons/menu/workspace-settings-white.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg index 8033938..07d31ed 100644 --- a/frontend/public/logo.svg +++ b/frontend/public/logo.svg @@ -1,5 +1,5 @@ - + - + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9c82c9b..4afa6b3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ +import { App as AntdApp, ConfigProvider } from 'antd'; import { useEffect, useState } from 'react'; import { BrowserRouter, Route } from 'react-router'; import { Routes } from 'react-router'; import { userApi } from './entity/users'; -import { OauthStorageComponent } from './features/storages/OauthStorageComponent'; import { AuthPageComponent } from './pages/AuthPageComponent'; +import { OAuthCallbackPage } from './pages/OAuthCallbackPage'; import { MainScreenComponent } from './widgets/main/MainScreenComponent'; function App() { @@ -20,15 +21,25 @@ function App() { }, []); return ( - - - : } /> - : } - /> - - + + + + + } /> + : } + /> + + + + ); } diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index edb5fbf..15f4b90 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -1,3 +1,15 @@ +interface RuntimeConfig { + IS_CLOUD?: string; + GITHUB_CLIENT_ID?: string; + GOOGLE_CLIENT_ID?: string; +} + +declare global { + interface Window { + __RUNTIME_CONFIG__?: RuntimeConfig; + } +} + export function getApplicationServer() { const origin = window.location.origin; const url = new URL(origin); @@ -14,3 +26,21 @@ export function getApplicationServer() { export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://postgresus.com/storages/google-oauth'; export const APP_VERSION = (import.meta.env.VITE_APP_VERSION as string) || 'dev'; + +// First try runtime config, then build-time env var, then default to false +export const IS_CLOUD = + window.__RUNTIME_CONFIG__?.IS_CLOUD === 'true' || import.meta.env.VITE_IS_CLOUD === 'true'; + +export const GITHUB_CLIENT_ID = + window.__RUNTIME_CONFIG__?.GITHUB_CLIENT_ID || import.meta.env.VITE_GITHUB_CLIENT_ID || ''; + +export const GOOGLE_CLIENT_ID = + window.__RUNTIME_CONFIG__?.GOOGLE_CLIENT_ID || import.meta.env.VITE_GOOGLE_CLIENT_ID || ''; + +export function getOAuthRedirectUri(): string { + return `${window.location.origin}/auth/callback`; +} + +export function isOAuthEnabled(): boolean { + return IS_CLOUD && (!!GITHUB_CLIENT_ID || !!GOOGLE_CLIENT_ID); +} diff --git a/frontend/src/entity/audit-logs/api/auditLogApi.ts b/frontend/src/entity/audit-logs/api/auditLogApi.ts new file mode 100644 index 0000000..fc5f22a --- /dev/null +++ b/frontend/src/entity/audit-logs/api/auditLogApi.ts @@ -0,0 +1,56 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { GetAuditLogsRequest } from '../model/GetAuditLogsRequest'; +import type { GetAuditLogsResponse } from '../model/GetAuditLogsResponse'; + +export const auditLogApi = { + async getGlobalAuditLogs(request?: GetAuditLogsRequest): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + + let url = `${getApplicationServer()}/api/v1/audit-logs/global`; + const params = new URLSearchParams(); + + if (request?.limit) { + params.append('limit', request.limit.toString()); + } + if (request?.offset) { + params.append('offset', request.offset.toString()); + } + if (request?.beforeDate) { + params.append('beforeDate', request.beforeDate); + } + + if (params.toString()) { + url += `?${params.toString()}`; + } + + return apiHelper.fetchGetJson(url, requestOptions); + }, + + async getUserAuditLogs( + userId: string, + request?: GetAuditLogsRequest, + ): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + + let url = `${getApplicationServer()}/api/v1/audit-logs/users/${userId}`; + const params = new URLSearchParams(); + + if (request?.limit) { + params.append('limit', request.limit.toString()); + } + if (request?.offset) { + params.append('offset', request.offset.toString()); + } + if (request?.beforeDate) { + params.append('beforeDate', request.beforeDate); + } + + if (params.toString()) { + url += `?${params.toString()}`; + } + + return apiHelper.fetchGetJson(url, requestOptions); + }, +}; diff --git a/frontend/src/entity/audit-logs/index.ts b/frontend/src/entity/audit-logs/index.ts new file mode 100644 index 0000000..c075e72 --- /dev/null +++ b/frontend/src/entity/audit-logs/index.ts @@ -0,0 +1,7 @@ +// APIs +export { auditLogApi } from './api/auditLogApi'; + +// Types +export type { AuditLog } from './model/AuditLog'; +export type { GetAuditLogsRequest } from './model/GetAuditLogsRequest'; +export type { GetAuditLogsResponse } from './model/GetAuditLogsResponse'; diff --git a/frontend/src/entity/audit-logs/model/AuditLog.ts b/frontend/src/entity/audit-logs/model/AuditLog.ts new file mode 100644 index 0000000..686ee1f --- /dev/null +++ b/frontend/src/entity/audit-logs/model/AuditLog.ts @@ -0,0 +1,10 @@ +export interface AuditLog { + id: string; + userId?: string; + workspaceId?: string; + message: string; + createdAt: string; + userEmail?: string; + userName?: string; + workspaceName?: string; +} diff --git a/frontend/src/entity/audit-logs/model/GetAuditLogsRequest.ts b/frontend/src/entity/audit-logs/model/GetAuditLogsRequest.ts new file mode 100644 index 0000000..b72fdeb --- /dev/null +++ b/frontend/src/entity/audit-logs/model/GetAuditLogsRequest.ts @@ -0,0 +1,5 @@ +export interface GetAuditLogsRequest { + limit?: number; + offset?: number; + beforeDate?: string; +} diff --git a/frontend/src/entity/audit-logs/model/GetAuditLogsResponse.ts b/frontend/src/entity/audit-logs/model/GetAuditLogsResponse.ts new file mode 100644 index 0000000..659ecf4 --- /dev/null +++ b/frontend/src/entity/audit-logs/model/GetAuditLogsResponse.ts @@ -0,0 +1,8 @@ +import type { AuditLog } from './AuditLog'; + +export interface GetAuditLogsResponse { + auditLogs: AuditLog[]; + total: number; + limit: number; + offset: number; +} diff --git a/frontend/src/entity/databases/api/databaseApi.ts b/frontend/src/entity/databases/api/databaseApi.ts index 0f275b7..ea07428 100644 --- a/frontend/src/entity/databases/api/databaseApi.ts +++ b/frontend/src/entity/databases/api/databaseApi.ts @@ -31,10 +31,10 @@ export const databaseApi = { ); }, - async getDatabases() { + async getDatabases(workspaceId: string) { const requestOptions: RequestOptions = new RequestOptions(); return apiHelper.fetchGetJson( - `${getApplicationServer()}/api/v1/databases`, + `${getApplicationServer()}/api/v1/databases?workspace_id=${workspaceId}`, requestOptions, true, ); diff --git a/frontend/src/entity/databases/model/Database.ts b/frontend/src/entity/databases/model/Database.ts index ec7afad..fdc84a6 100644 --- a/frontend/src/entity/databases/model/Database.ts +++ b/frontend/src/entity/databases/model/Database.ts @@ -6,6 +6,7 @@ import type { PostgresqlDatabase } from './postgresql/PostgresqlDatabase'; export interface Database { id: string; name: string; + workspaceId: string; type: DatabaseType; postgresql?: PostgresqlDatabase; diff --git a/frontend/src/entity/notifiers/api/notifierApi.ts b/frontend/src/entity/notifiers/api/notifierApi.ts index 3474142..8f2ee7b 100644 --- a/frontend/src/entity/notifiers/api/notifierApi.ts +++ b/frontend/src/entity/notifiers/api/notifierApi.ts @@ -22,10 +22,10 @@ export const notifierApi = { ); }, - async getNotifiers() { + async getNotifiers(workspaceId: string) { const requestOptions: RequestOptions = new RequestOptions(); return apiHelper.fetchGetJson( - `${getApplicationServer()}/api/v1/notifiers`, + `${getApplicationServer()}/api/v1/notifiers?workspace_id=${workspaceId}`, requestOptions, true, ); diff --git a/frontend/src/entity/notifiers/models/Notifier.ts b/frontend/src/entity/notifiers/models/Notifier.ts index 6675e26..e09a74f 100644 --- a/frontend/src/entity/notifiers/models/Notifier.ts +++ b/frontend/src/entity/notifiers/models/Notifier.ts @@ -11,6 +11,7 @@ export interface Notifier { name: string; notifierType: NotifierType; lastSendError?: string; + workspaceId: string; // specific notifier telegramNotifier?: TelegramNotifier; diff --git a/frontend/src/entity/storages/api/storageApi.ts b/frontend/src/entity/storages/api/storageApi.ts index 61dced6..99abf79 100644 --- a/frontend/src/entity/storages/api/storageApi.ts +++ b/frontend/src/entity/storages/api/storageApi.ts @@ -22,10 +22,10 @@ export const storageApi = { ); }, - async getStorages() { + async getStorages(workspaceId: string) { const requestOptions: RequestOptions = new RequestOptions(); return apiHelper.fetchGetJson( - `${getApplicationServer()}/api/v1/storages`, + `${getApplicationServer()}/api/v1/storages?workspace_id=${workspaceId}`, requestOptions, true, ); diff --git a/frontend/src/entity/storages/models/Storage.ts b/frontend/src/entity/storages/models/Storage.ts index 38b27e5..5e7e1fe 100644 --- a/frontend/src/entity/storages/models/Storage.ts +++ b/frontend/src/entity/storages/models/Storage.ts @@ -9,6 +9,7 @@ export interface Storage { type: StorageType; name: string; lastSaveError?: string; + workspaceId: string; // specific storage types localStorage?: LocalStorage; diff --git a/frontend/src/entity/users/api/settingsApi.ts b/frontend/src/entity/users/api/settingsApi.ts new file mode 100644 index 0000000..28b8ba2 --- /dev/null +++ b/frontend/src/entity/users/api/settingsApi.ts @@ -0,0 +1,23 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { UsersSettings } from '../model/UsersSettings'; + +export const settingsApi = { + async getSettings(): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/users/settings`, + requestOptions, + ); + }, + + async updateSettings(settings: UsersSettings): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(settings)); + return apiHelper.fetchPutJson( + `${getApplicationServer()}/api/v1/users/settings`, + requestOptions, + ); + }, +}; diff --git a/frontend/src/entity/users/api/userApi.ts b/frontend/src/entity/users/api/userApi.ts index ff6da42..39738f1 100644 --- a/frontend/src/entity/users/api/userApi.ts +++ b/frontend/src/entity/users/api/userApi.ts @@ -2,9 +2,18 @@ import { getApplicationServer } from '../../../constants'; import RequestOptions from '../../../shared/api/RequestOptions'; import { accessTokenHelper } from '../../../shared/api/accessTokenHelper'; import { apiHelper } from '../../../shared/api/apiHelper'; +import type { ChangePasswordRequest } from '../model/ChangePasswordRequest'; +import type { InviteUserRequest } from '../model/InviteUserRequest'; +import type { InviteUserResponse } from '../model/InviteUserResponse'; +import type { IsAdminHasPasswordResponse } from '../model/IsAdminHasPasswordResponse'; +import type { OAuthCallbackRequest } from '../model/OAuthCallbackRequest'; +import type { OAuthCallbackResponse } from '../model/OAuthCallbackResponse'; +import type { SetAdminPasswordRequest } from '../model/SetAdminPasswordRequest'; import type { SignInRequest } from '../model/SignInRequest'; import type { SignInResponse } from '../model/SignInResponse'; import type { SignUpRequest } from '../model/SignUpRequest'; +import type { UpdateUserInfoRequest } from '../model/UpdateUserInfoRequest'; +import type { UserProfile } from '../model/UserProfile'; const listeners: (() => void)[] = []; @@ -54,6 +63,77 @@ export const userApi = { }); }, + async isAdminHasPassword(): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/users/admin/has-password`, + requestOptions, + ); + }, + + async setAdminPassword(request: SetAdminPasswordRequest): Promise<{ message: string }> { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/users/admin/set-password`, + requestOptions, + ); + }, + + async changePassword(request: ChangePasswordRequest): Promise<{ message: string }> { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + return apiHelper.fetchPutJson( + `${getApplicationServer()}/api/v1/users/change-password`, + requestOptions, + ); + }, + + async inviteUser(request: InviteUserRequest): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + return apiHelper.fetchPostJson(`${getApplicationServer()}/api/v1/users/invite`, requestOptions); + }, + + async getCurrentUser(): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson(`${getApplicationServer()}/api/v1/users/me`, requestOptions); + }, + + async updateUserInfo(request: UpdateUserInfoRequest): Promise<{ message: string }> { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + return apiHelper.fetchPutJson(`${getApplicationServer()}/api/v1/users/me`, requestOptions); + }, + + async handleGitHubOAuth(request: OAuthCallbackRequest): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + + return apiHelper + .fetchPostJson(`${getApplicationServer()}/api/v1/auth/github/callback`, requestOptions) + .then((response: unknown): OAuthCallbackResponse => { + const typedResponse = response as OAuthCallbackResponse; + saveAuthorizedData(typedResponse.token, typedResponse.userId); + notifyAuthListeners(); + return typedResponse; + }); + }, + + async handleGoogleOAuth(request: OAuthCallbackRequest): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + + return apiHelper + .fetchPostJson(`${getApplicationServer()}/api/v1/auth/google/callback`, requestOptions) + .then((response: unknown): OAuthCallbackResponse => { + const typedResponse = response as OAuthCallbackResponse; + saveAuthorizedData(typedResponse.token, typedResponse.userId); + notifyAuthListeners(); + return typedResponse; + }); + }, + isAuthorized: (): boolean => !!accessTokenHelper.getAccessToken(), logout: () => { diff --git a/frontend/src/entity/users/api/userManagementApi.ts b/frontend/src/entity/users/api/userManagementApi.ts new file mode 100644 index 0000000..39d5a99 --- /dev/null +++ b/frontend/src/entity/users/api/userManagementApi.ts @@ -0,0 +1,71 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { ChangeUserRoleRequest } from '../model/ChangeUserRoleRequest'; +import type { ListUsersRequest } from '../model/ListUsersRequest'; +import type { ListUsersResponse } from '../model/ListUsersResponse'; +import type { UserProfile } from '../model/UserProfile'; + +export const userManagementApi = { + async getUsers(request?: ListUsersRequest): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + + let url = `${getApplicationServer()}/api/v1/users`; + const params = new URLSearchParams(); + + if (request?.limit) { + params.append('limit', request.limit.toString()); + } + if (request?.offset) { + params.append('offset', request.offset.toString()); + } + if (request?.beforeDate) { + params.append('beforeDate', request.beforeDate); + } + if (request?.query) { + params.append('query', request.query); + } + + if (params.toString()) { + url += `?${params.toString()}`; + } + + return apiHelper.fetchGetJson(url, requestOptions); + }, + + async getUserProfile(userId: string): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/users/${userId}`, + requestOptions, + ); + }, + + async deactivateUser(userId: string): Promise<{ message: string }> { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/users/${userId}/deactivate`, + requestOptions, + ); + }, + + async activateUser(userId: string): Promise<{ message: string }> { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/users/${userId}/activate`, + requestOptions, + ); + }, + + async changeUserRole( + userId: string, + roleRequest: ChangeUserRoleRequest, + ): Promise<{ message: string }> { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(roleRequest)); + return apiHelper.fetchPutJson( + `${getApplicationServer()}/api/v1/users/${userId}/role`, + requestOptions, + ); + }, +}; diff --git a/frontend/src/entity/users/index.ts b/frontend/src/entity/users/index.ts index f753ecc..d443267 100644 --- a/frontend/src/entity/users/index.ts +++ b/frontend/src/entity/users/index.ts @@ -1 +1,22 @@ +// APIs export { userApi } from './api/userApi'; +export { settingsApi } from './api/settingsApi'; +export { userManagementApi } from './api/userManagementApi'; + +// Types and Enums +export type { SignInRequest } from './model/SignInRequest'; +export type { SignInResponse } from './model/SignInResponse'; +export type { SignUpRequest } from './model/SignUpRequest'; +export type { SetAdminPasswordRequest } from './model/SetAdminPasswordRequest'; +export type { IsAdminHasPasswordResponse } from './model/IsAdminHasPasswordResponse'; +export type { ChangePasswordRequest } from './model/ChangePasswordRequest'; +export type { InviteUserRequest } from './model/InviteUserRequest'; +export type { InviteUserResponse } from './model/InviteUserResponse'; +export type { UpdateUserInfoRequest } from './model/UpdateUserInfoRequest'; +export type { UserProfile } from './model/UserProfile'; +export type { ListUsersRequest } from './model/ListUsersRequest'; +export type { ListUsersResponse } from './model/ListUsersResponse'; +export type { ChangeUserRoleRequest } from './model/ChangeUserRoleRequest'; +export type { UsersSettings } from './model/UsersSettings'; +export { UserRole } from './model/UserRole'; +export { WorkspaceRole } from './model/WorkspaceRole'; diff --git a/frontend/src/entity/users/model/ChangePasswordRequest.ts b/frontend/src/entity/users/model/ChangePasswordRequest.ts new file mode 100644 index 0000000..3fe3ffa --- /dev/null +++ b/frontend/src/entity/users/model/ChangePasswordRequest.ts @@ -0,0 +1,3 @@ +export interface ChangePasswordRequest { + newPassword: string; +} diff --git a/frontend/src/entity/users/model/ChangeUserRoleRequest.ts b/frontend/src/entity/users/model/ChangeUserRoleRequest.ts new file mode 100644 index 0000000..a91fb71 --- /dev/null +++ b/frontend/src/entity/users/model/ChangeUserRoleRequest.ts @@ -0,0 +1,5 @@ +import type { UserRole } from './UserRole'; + +export interface ChangeUserRoleRequest { + role: UserRole; +} diff --git a/frontend/src/entity/users/model/InviteUserRequest.ts b/frontend/src/entity/users/model/InviteUserRequest.ts new file mode 100644 index 0000000..7ef5ce8 --- /dev/null +++ b/frontend/src/entity/users/model/InviteUserRequest.ts @@ -0,0 +1,7 @@ +import type { WorkspaceRole } from './WorkspaceRole'; + +export interface InviteUserRequest { + email: string; + intendedWorkspaceId?: string; + intendedWorkspaceRole?: WorkspaceRole; +} diff --git a/frontend/src/entity/users/model/InviteUserResponse.ts b/frontend/src/entity/users/model/InviteUserResponse.ts new file mode 100644 index 0000000..528f8e0 --- /dev/null +++ b/frontend/src/entity/users/model/InviteUserResponse.ts @@ -0,0 +1,9 @@ +import type { WorkspaceRole } from './WorkspaceRole'; + +export interface InviteUserResponse { + id: string; + email: string; + intendedWorkspaceId?: string; + intendedWorkspaceRole?: WorkspaceRole; + createdAt: string; +} diff --git a/frontend/src/entity/users/model/IsAdminHasPasswordResponse.ts b/frontend/src/entity/users/model/IsAdminHasPasswordResponse.ts new file mode 100644 index 0000000..3d00c3b --- /dev/null +++ b/frontend/src/entity/users/model/IsAdminHasPasswordResponse.ts @@ -0,0 +1,3 @@ +export interface IsAdminHasPasswordResponse { + hasPassword: boolean; +} diff --git a/frontend/src/entity/users/model/ListUsersRequest.ts b/frontend/src/entity/users/model/ListUsersRequest.ts new file mode 100644 index 0000000..1733e5a --- /dev/null +++ b/frontend/src/entity/users/model/ListUsersRequest.ts @@ -0,0 +1,6 @@ +export interface ListUsersRequest { + limit?: number; + offset?: number; + beforeDate?: string; + query?: string; +} diff --git a/frontend/src/entity/users/model/ListUsersResponse.ts b/frontend/src/entity/users/model/ListUsersResponse.ts new file mode 100644 index 0000000..26da3d5 --- /dev/null +++ b/frontend/src/entity/users/model/ListUsersResponse.ts @@ -0,0 +1,6 @@ +import type { UserProfile } from './UserProfile'; + +export interface ListUsersResponse { + users: UserProfile[]; + total: number; +} diff --git a/frontend/src/entity/users/model/OAuthCallbackRequest.ts b/frontend/src/entity/users/model/OAuthCallbackRequest.ts new file mode 100644 index 0000000..d33b224 --- /dev/null +++ b/frontend/src/entity/users/model/OAuthCallbackRequest.ts @@ -0,0 +1,4 @@ +export interface OAuthCallbackRequest { + code: string; + redirectUri: string; +} diff --git a/frontend/src/entity/users/model/OAuthCallbackResponse.ts b/frontend/src/entity/users/model/OAuthCallbackResponse.ts new file mode 100644 index 0000000..f7441ef --- /dev/null +++ b/frontend/src/entity/users/model/OAuthCallbackResponse.ts @@ -0,0 +1,6 @@ +export interface OAuthCallbackResponse { + userId: string; + email: string; + token: string; + isNewUser: boolean; +} diff --git a/frontend/src/entity/users/model/SetAdminPasswordRequest.ts b/frontend/src/entity/users/model/SetAdminPasswordRequest.ts new file mode 100644 index 0000000..c5f0b88 --- /dev/null +++ b/frontend/src/entity/users/model/SetAdminPasswordRequest.ts @@ -0,0 +1,3 @@ +export interface SetAdminPasswordRequest { + password: string; +} diff --git a/frontend/src/entity/users/model/SignUpRequest.ts b/frontend/src/entity/users/model/SignUpRequest.ts index 0e97ca6..e21aeb0 100644 --- a/frontend/src/entity/users/model/SignUpRequest.ts +++ b/frontend/src/entity/users/model/SignUpRequest.ts @@ -1,4 +1,5 @@ export interface SignUpRequest { email: string; password: string; + name: string; } diff --git a/frontend/src/entity/users/model/UpdateUserInfoRequest.ts b/frontend/src/entity/users/model/UpdateUserInfoRequest.ts new file mode 100644 index 0000000..976a63b --- /dev/null +++ b/frontend/src/entity/users/model/UpdateUserInfoRequest.ts @@ -0,0 +1,4 @@ +export interface UpdateUserInfoRequest { + name?: string; + email?: string; +} diff --git a/frontend/src/entity/users/model/UserProfile.ts b/frontend/src/entity/users/model/UserProfile.ts new file mode 100644 index 0000000..8a82c87 --- /dev/null +++ b/frontend/src/entity/users/model/UserProfile.ts @@ -0,0 +1,10 @@ +import type { UserRole } from './UserRole'; + +export interface UserProfile { + id: string; + email: string; + name: string; + role: UserRole; + isActive: boolean; + createdAt: Date; +} diff --git a/frontend/src/entity/users/model/UserRole.ts b/frontend/src/entity/users/model/UserRole.ts new file mode 100644 index 0000000..3243fde --- /dev/null +++ b/frontend/src/entity/users/model/UserRole.ts @@ -0,0 +1,4 @@ +export enum UserRole { + ADMIN = 'ADMIN', + MEMBER = 'MEMBER', +} diff --git a/frontend/src/entity/users/model/UsersSettings.ts b/frontend/src/entity/users/model/UsersSettings.ts new file mode 100644 index 0000000..67ff4e2 --- /dev/null +++ b/frontend/src/entity/users/model/UsersSettings.ts @@ -0,0 +1,5 @@ +export interface UsersSettings { + isAllowExternalRegistrations: boolean; + isAllowMemberInvitations: boolean; + isMemberAllowedToCreateWorkspaces: boolean; +} diff --git a/frontend/src/entity/users/model/WorkspaceRole.ts b/frontend/src/entity/users/model/WorkspaceRole.ts new file mode 100644 index 0000000..499b420 --- /dev/null +++ b/frontend/src/entity/users/model/WorkspaceRole.ts @@ -0,0 +1,6 @@ +export enum WorkspaceRole { + OWNER = 'WORKSPACE_OWNER', + ADMIN = 'WORKSPACE_ADMIN', + MEMBER = 'WORKSPACE_MEMBER', + VIEWER = 'WORKSPACE_VIEWER', +} diff --git a/frontend/src/entity/workspaces/api/workspaceApi.ts b/frontend/src/entity/workspaces/api/workspaceApi.ts new file mode 100644 index 0000000..16ca91b --- /dev/null +++ b/frontend/src/entity/workspaces/api/workspaceApi.ts @@ -0,0 +1,76 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { GetAuditLogsResponse } from '../../audit-logs/model/GetAuditLogsResponse'; +import type { CreateWorkspaceRequest } from '../model/CreateWorkspaceRequest'; +import type { ListWorkspacesResponse } from '../model/ListWorkspacesResponse'; +import type { Workspace } from '../model/Workspace'; +import type { WorkspaceResponse } from '../model/WorkspaceResponse'; + +export const workspaceApi = { + async createWorkspace(request: CreateWorkspaceRequest): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + return apiHelper.fetchPostJson(`${getApplicationServer()}/api/v1/workspaces`, requestOptions); + }, + + async getWorkspaces(): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson(`${getApplicationServer()}/api/v1/workspaces`, requestOptions); + }, + + async getWorkspace(workspaceId: string): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/workspaces/${workspaceId}`, + requestOptions, + ); + }, + + async updateWorkspace(workspaceId: string, workspace: Workspace): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(workspace)); + return apiHelper.fetchPutJson( + `${getApplicationServer()}/api/v1/workspaces/${workspaceId}`, + requestOptions, + ); + }, + + async deleteWorkspace(workspaceId: string): Promise<{ message: string }> { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchDeleteJson( + `${getApplicationServer()}/api/v1/workspaces/${workspaceId}`, + requestOptions, + ); + }, + + async getWorkspaceAuditLogs( + workspaceId: string, + params?: { + limit?: number; + offset?: number; + beforeDate?: string; + }, + ): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + + let url = `${getApplicationServer()}/api/v1/workspaces/${workspaceId}/audit-logs`; + const urlParams = new URLSearchParams(); + + if (params?.limit) { + urlParams.append('limit', params.limit.toString()); + } + if (params?.offset) { + urlParams.append('offset', params.offset.toString()); + } + if (params?.beforeDate) { + urlParams.append('beforeDate', params.beforeDate); + } + + if (urlParams.toString()) { + url += `?${urlParams.toString()}`; + } + + return apiHelper.fetchGetJson(url, requestOptions); + }, +}; diff --git a/frontend/src/entity/workspaces/api/workspaceMembershipApi.ts b/frontend/src/entity/workspaces/api/workspaceMembershipApi.ts new file mode 100644 index 0000000..c73d6da --- /dev/null +++ b/frontend/src/entity/workspaces/api/workspaceMembershipApi.ts @@ -0,0 +1,60 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { AddMemberRequest } from '../model/AddMemberRequest'; +import type { AddMemberResponse } from '../model/AddMemberResponse'; +import type { ChangeMemberRoleRequest } from '../model/ChangeMemberRoleRequest'; +import type { GetMembersResponse } from '../model/GetMembersResponse'; +import type { TransferOwnershipRequest } from '../model/TransferOwnershipRequest'; + +export const workspaceMembershipApi = { + async getMembers(workspaceId: string): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/workspaces/memberships/${workspaceId}/members`, + requestOptions, + ); + }, + + async addMember(workspaceId: string, request: AddMemberRequest): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/workspaces/memberships/${workspaceId}/members`, + requestOptions, + ); + }, + + async changeMemberRole( + workspaceId: string, + userId: string, + request: ChangeMemberRoleRequest, + ): Promise<{ message: string }> { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + return apiHelper.fetchPutJson( + `${getApplicationServer()}/api/v1/workspaces/memberships/${workspaceId}/members/${userId}/role`, + requestOptions, + ); + }, + + async removeMember(workspaceId: string, userId: string): Promise<{ message: string }> { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchDeleteJson( + `${getApplicationServer()}/api/v1/workspaces/memberships/${workspaceId}/members/${userId}`, + requestOptions, + ); + }, + + async transferOwnership( + workspaceId: string, + request: TransferOwnershipRequest, + ): Promise<{ message: string }> { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/workspaces/memberships/${workspaceId}/transfer-ownership`, + requestOptions, + ); + }, +}; diff --git a/frontend/src/entity/workspaces/index.ts b/frontend/src/entity/workspaces/index.ts new file mode 100644 index 0000000..9eec89d --- /dev/null +++ b/frontend/src/entity/workspaces/index.ts @@ -0,0 +1,17 @@ +// APIs +export { workspaceApi } from './api/workspaceApi'; +export { workspaceMembershipApi } from './api/workspaceMembershipApi'; + +// Models and Types +export type { Workspace } from './model/Workspace'; +export type { WorkspaceMembership } from './model/WorkspaceMembership'; +export type { CreateWorkspaceRequest } from './model/CreateWorkspaceRequest'; +export type { WorkspaceResponse } from './model/WorkspaceResponse'; +export type { ListWorkspacesResponse } from './model/ListWorkspacesResponse'; +export type { AddMemberRequest } from './model/AddMemberRequest'; +export type { AddMemberResponse } from './model/AddMemberResponse'; +export { AddMemberStatusEnum } from './model/AddMemberStatus'; +export type { ChangeMemberRoleRequest } from './model/ChangeMemberRoleRequest'; +export type { TransferOwnershipRequest } from './model/TransferOwnershipRequest'; +export type { WorkspaceMemberResponse } from './model/WorkspaceMemberResponse'; +export type { GetMembersResponse } from './model/GetMembersResponse'; diff --git a/frontend/src/entity/workspaces/model/AddMemberRequest.ts b/frontend/src/entity/workspaces/model/AddMemberRequest.ts new file mode 100644 index 0000000..eb3c253 --- /dev/null +++ b/frontend/src/entity/workspaces/model/AddMemberRequest.ts @@ -0,0 +1,6 @@ +import { WorkspaceRole } from '../../users/model/WorkspaceRole'; + +export interface AddMemberRequest { + email: string; + role: WorkspaceRole; +} diff --git a/frontend/src/entity/workspaces/model/AddMemberResponse.ts b/frontend/src/entity/workspaces/model/AddMemberResponse.ts new file mode 100644 index 0000000..0072e3a --- /dev/null +++ b/frontend/src/entity/workspaces/model/AddMemberResponse.ts @@ -0,0 +1,5 @@ +import type { AddMemberStatusEnum } from './AddMemberStatus'; + +export interface AddMemberResponse { + status: AddMemberStatusEnum; +} diff --git a/frontend/src/entity/workspaces/model/AddMemberStatus.ts b/frontend/src/entity/workspaces/model/AddMemberStatus.ts new file mode 100644 index 0000000..fb029a4 --- /dev/null +++ b/frontend/src/entity/workspaces/model/AddMemberStatus.ts @@ -0,0 +1,4 @@ +export enum AddMemberStatusEnum { + INVITED = 'INVITED', + ADDED = 'ADDED', +} diff --git a/frontend/src/entity/workspaces/model/ChangeMemberRoleRequest.ts b/frontend/src/entity/workspaces/model/ChangeMemberRoleRequest.ts new file mode 100644 index 0000000..224b175 --- /dev/null +++ b/frontend/src/entity/workspaces/model/ChangeMemberRoleRequest.ts @@ -0,0 +1,5 @@ +import { WorkspaceRole } from '../../users/model/WorkspaceRole'; + +export interface ChangeMemberRoleRequest { + role: WorkspaceRole; +} diff --git a/frontend/src/entity/workspaces/model/CreateWorkspaceRequest.ts b/frontend/src/entity/workspaces/model/CreateWorkspaceRequest.ts new file mode 100644 index 0000000..0f20ec8 --- /dev/null +++ b/frontend/src/entity/workspaces/model/CreateWorkspaceRequest.ts @@ -0,0 +1,3 @@ +export interface CreateWorkspaceRequest { + name: string; +} diff --git a/frontend/src/entity/workspaces/model/GetMembersResponse.ts b/frontend/src/entity/workspaces/model/GetMembersResponse.ts new file mode 100644 index 0000000..eecce0b --- /dev/null +++ b/frontend/src/entity/workspaces/model/GetMembersResponse.ts @@ -0,0 +1,5 @@ +import type { WorkspaceMemberResponse } from './WorkspaceMemberResponse'; + +export interface GetMembersResponse { + members: WorkspaceMemberResponse[]; +} diff --git a/frontend/src/entity/workspaces/model/ListWorkspacesResponse.ts b/frontend/src/entity/workspaces/model/ListWorkspacesResponse.ts new file mode 100644 index 0000000..57988c2 --- /dev/null +++ b/frontend/src/entity/workspaces/model/ListWorkspacesResponse.ts @@ -0,0 +1,5 @@ +import type { WorkspaceResponse } from './WorkspaceResponse'; + +export interface ListWorkspacesResponse { + workspaces: WorkspaceResponse[]; +} diff --git a/frontend/src/entity/workspaces/model/TransferOwnershipRequest.ts b/frontend/src/entity/workspaces/model/TransferOwnershipRequest.ts new file mode 100644 index 0000000..14d0c26 --- /dev/null +++ b/frontend/src/entity/workspaces/model/TransferOwnershipRequest.ts @@ -0,0 +1,3 @@ +export interface TransferOwnershipRequest { + newOwnerEmail: string; +} diff --git a/frontend/src/entity/workspaces/model/Workspace.ts b/frontend/src/entity/workspaces/model/Workspace.ts new file mode 100644 index 0000000..3ef7b47 --- /dev/null +++ b/frontend/src/entity/workspaces/model/Workspace.ts @@ -0,0 +1,5 @@ +export interface Workspace { + id: string; + name: string; + createdAt: Date; +} diff --git a/frontend/src/entity/workspaces/model/WorkspaceMemberResponse.ts b/frontend/src/entity/workspaces/model/WorkspaceMemberResponse.ts new file mode 100644 index 0000000..a1beae9 --- /dev/null +++ b/frontend/src/entity/workspaces/model/WorkspaceMemberResponse.ts @@ -0,0 +1,10 @@ +import { WorkspaceRole } from '../../users/model/WorkspaceRole'; + +export interface WorkspaceMemberResponse { + id: string; + userId: string; + email: string; + name: string; + role: WorkspaceRole; + createdAt: Date; +} diff --git a/frontend/src/entity/workspaces/model/WorkspaceMembership.ts b/frontend/src/entity/workspaces/model/WorkspaceMembership.ts new file mode 100644 index 0000000..217cde7 --- /dev/null +++ b/frontend/src/entity/workspaces/model/WorkspaceMembership.ts @@ -0,0 +1,9 @@ +import { WorkspaceRole } from '../../users/model/WorkspaceRole'; + +export interface WorkspaceMembership { + id: string; + userId: string; + workspaceId: string; + role: WorkspaceRole; + createdAt: Date; +} diff --git a/frontend/src/entity/workspaces/model/WorkspaceResponse.ts b/frontend/src/entity/workspaces/model/WorkspaceResponse.ts new file mode 100644 index 0000000..fe04391 --- /dev/null +++ b/frontend/src/entity/workspaces/model/WorkspaceResponse.ts @@ -0,0 +1,8 @@ +import { WorkspaceRole } from '../../users/model/WorkspaceRole'; + +export interface WorkspaceResponse { + id: string; + name: string; + createdAt: Date; + userRole?: WorkspaceRole; +} diff --git a/frontend/src/features/backups/ui/BackupsComponent.tsx b/frontend/src/features/backups/ui/BackupsComponent.tsx index e9a0b5d..e51528c 100644 --- a/frontend/src/features/backups/ui/BackupsComponent.tsx +++ b/frontend/src/features/backups/ui/BackupsComponent.tsx @@ -20,9 +20,10 @@ import { RestoresComponent } from '../../restores'; interface Props { database: Database; + isCanManageDBs: boolean; } -export const BackupsComponent = ({ database }: Props) => { +export const BackupsComponent = ({ database, isCanManageDBs }: Props) => { const [isBackupsLoading, setIsBackupsLoading] = useState(false); const [backups, setBackups] = useState([]); @@ -287,16 +288,18 @@ export const BackupsComponent = ({ database }: Props) => { ) : ( <> - - { - if (deletingBackupId) return; - setDeleteConfimationId(record.id); - }} - style={{ color: '#ff0000', opacity: deletingBackupId ? 0.2 : 1 }} - /> - + {isCanManageDBs && ( + + { + if (deletingBackupId) return; + setDeleteConfimationId(record.id); + }} + style={{ color: '#ff0000', opacity: deletingBackupId ? 0.2 : 1 }} + /> + + )} { setShowingRestoresBackupId(record.id); }} style={{ - color: '#0d6efd', + color: '#155dfc', }} /> {downloadingBackupId === record.id ? ( - + ) : ( { }} style={{ opacity: downloadingBackupId ? 0.2 : 1, - color: '#0d6efd', + color: '#155dfc', }} /> )} diff --git a/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx b/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx index f417731..a8a98e7 100644 --- a/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx +++ b/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx @@ -123,7 +123,7 @@ export const EditBackupConfigComponent = ({ setIsStoragesLoading(true); try { - const storages = await storageApi.getStorages(); + const storages = await storageApi.getStorages(database.workspaceId); setStorages(storages); } catch (e) { alert((e as Error).message); @@ -150,7 +150,7 @@ export const EditBackupConfigComponent = ({ }, storage: undefined, cpuCount: 1, - storePeriod: Period.WEEK, + storePeriod: Period.THREE_MONTH, sendNotificationsOn: [], isRetryIfFailed: true, maxFailedTriesCount: 3, @@ -499,6 +499,7 @@ export const EditBackupConfigComponent = ({ setShowCreateStorage(false)} diff --git a/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx b/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx index b6b989f..aa61e1e 100644 --- a/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx +++ b/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx @@ -14,18 +14,20 @@ import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComp import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent'; interface Props { - onCreated: () => void; + workspaceId: string; + onCreated: () => void; onClose: () => void; } -export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => { +export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Props) => { const [isCreating, setIsCreating] = useState(false); const [backupConfig, setBackupConfig] = useState(); const [database, setDatabase] = useState({ id: undefined as unknown as string, name: '', - storePeriod: Period.WEEK, + workspaceId, + storePeriod: Period.MONTH, postgresql: { cpuCount: 1, @@ -124,6 +126,7 @@ export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => { onClose()} isShowBackButton onBack={() => setStep('backup-config')} diff --git a/frontend/src/features/databases/ui/DatabaseCardComponent.tsx b/frontend/src/features/databases/ui/DatabaseCardComponent.tsx index 4e5a0e3..d9af405 100644 --- a/frontend/src/features/databases/ui/DatabaseCardComponent.tsx +++ b/frontend/src/features/databases/ui/DatabaseCardComponent.tsx @@ -3,11 +3,10 @@ import dayjs from 'dayjs'; import { useEffect, useState } from 'react'; import { backupConfigApi } from '../../../entity/backups'; -import { type Database, DatabaseType } from '../../../entity/databases'; +import { type Database } from '../../../entity/databases'; import { HealthStatus } from '../../../entity/databases/model/HealthStatus'; import type { Storage } from '../../../entity/storages'; import { getStorageLogoFromType } from '../../../entity/storages/models/getStorageLogoFromType'; -import { getUserShortTimeFormat } from '../../../shared/time/getUserTimeFormat'; interface Props { database: Database; @@ -22,14 +21,6 @@ export const DatabaseCardComponent = ({ }: Props) => { const [storage, setStorage] = useState(); - let databaseIcon = ''; - let databaseType = ''; - - if (database.type === DatabaseType.POSTGRES) { - databaseIcon = '/icons/databases/postgresql.svg'; - databaseType = 'PostgreSQL'; - } - useEffect(() => { if (!database.id) return; @@ -57,13 +48,8 @@ export const DatabaseCardComponent = ({ )} -
-
Database type: {databaseType}
- databaseIcon -
- {storage && ( -
+
Storage: {storage.name}{' '} @@ -79,12 +65,7 @@ export const DatabaseCardComponent = ({ )} {database.lastBackupTime && ( -
- Last backup -
- {dayjs(database.lastBackupTime).format(getUserShortTimeFormat().format)} ( - {dayjs(database.lastBackupTime).fromNow()}) -
+
Last backup {dayjs(database.lastBackupTime).fromNow()}
)} {database.lastBackupErrorMessage && ( diff --git a/frontend/src/features/databases/ui/DatabaseComponent.tsx b/frontend/src/features/databases/ui/DatabaseComponent.tsx index c5cb6af..b5efb95 100644 --- a/frontend/src/features/databases/ui/DatabaseComponent.tsx +++ b/frontend/src/features/databases/ui/DatabaseComponent.tsx @@ -12,6 +12,7 @@ interface Props { databaseId: string; onDatabaseChanged: (database: Database) => void; onDatabaseDeleted: () => void; + isCanManageDBs: boolean; } export const DatabaseComponent = ({ @@ -19,6 +20,7 @@ export const DatabaseComponent = ({ databaseId, onDatabaseChanged, onDatabaseDeleted, + isCanManageDBs, }: Props) => { const [currentTab, setCurrentTab] = useState<'config' | 'backups' | 'metrics'>('backups'); @@ -65,12 +67,13 @@ export const DatabaseComponent = ({ onDatabaseDeleted={onDatabaseDeleted} editDatabase={editDatabase} setEditDatabase={setEditDatabase} + isCanManageDBs={isCanManageDBs} /> )} {currentTab === 'backups' && ( <> - + )}
diff --git a/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx b/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx index 0ca28ee..68d8900 100644 --- a/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx +++ b/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx @@ -19,6 +19,8 @@ interface Props { onDatabaseDeleted: () => void; editDatabase: Database | undefined; setEditDatabase: (database: Database | undefined) => void; + + isCanManageDBs: boolean; } export const DatabaseConfigComponent = ({ @@ -28,6 +30,7 @@ export const DatabaseConfigComponent = ({ onDatabaseDeleted, editDatabase, setEditDatabase, + isCanManageDBs, }: Props) => { const [isEditName, setIsEditName] = useState(false); const [isEditDatabaseSpecificDataSettings, setIsEditDatabaseSpecificDataSettings] = @@ -148,9 +151,12 @@ export const DatabaseConfigComponent = ({ {!isEditName ? (
{database.name} -
startEdit('name')}> - -
+ + {isCanManageDBs && ( +
startEdit('name')}> + +
+ )}
) : (
@@ -225,7 +231,7 @@ export const DatabaseConfigComponent = ({
Database settings
- {!isEditDatabaseSpecificDataSettings ? ( + {!isEditDatabaseSpecificDataSettings && isCanManageDBs ? (
startEdit('database')}>
@@ -258,7 +264,7 @@ export const DatabaseConfigComponent = ({
Backup config
- {!isEditBackupConfig ? ( + {!isEditBackupConfig && isCanManageDBs ? (
startEdit('backup-config')} @@ -298,7 +304,7 @@ export const DatabaseConfigComponent = ({
Healthcheck settings
- {!isEditHealthcheckSettings ? ( + {!isEditHealthcheckSettings && isCanManageDBs ? (
startEdit('healthcheck')}>
@@ -326,7 +332,7 @@ export const DatabaseConfigComponent = ({
Notifiers settings
- {!isEditNotifiersSettings ? ( + {!isEditNotifiersSettings && isCanManageDBs ? (
startEdit('notifiers')}>
@@ -338,6 +344,7 @@ export const DatabaseConfigComponent = ({
{isEditNotifiersSettings ? ( { + +export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }: Props) => { const [isLoading, setIsLoading] = useState(true); const [databases, setDatabases] = useState([]); const [searchQuery, setSearchQuery] = useState(''); @@ -24,7 +28,7 @@ export const DatabasesComponent = ({ contentHeight }: Props) => { } databaseApi - .getDatabases() + .getDatabases(workspace.id) .then((databases) => { setDatabases(databases); if (!selectedDatabaseId && !isSilent) { @@ -72,7 +76,7 @@ export const DatabasesComponent = ({ contentHeight }: Props) => { > {databases.length >= 5 && ( <> - {addDatabaseButton} + {isCanManageDBs && addDatabaseButton}
{
)} - {databases.length < 5 && addDatabaseButton} + {databases.length < 5 && isCanManageDBs && addDatabaseButton}
Database - is a thing we are backing up @@ -120,6 +124,7 @@ export const DatabasesComponent = ({ contentHeight }: Props) => { databases.filter((database) => database.id !== selectedDatabaseId)[0]?.id, ); }} + isCanManageDBs={isCanManageDBs} /> )}
@@ -135,6 +140,7 @@ export const DatabasesComponent = ({ contentHeight }: Props) => {
{ loadDatabases(); setIsShowAddDatabase(false); diff --git a/frontend/src/features/databases/ui/edit/EditDatabaseNotifiersComponent.tsx b/frontend/src/features/databases/ui/edit/EditDatabaseNotifiersComponent.tsx index 73d6182..c4aa60a 100644 --- a/frontend/src/features/databases/ui/edit/EditDatabaseNotifiersComponent.tsx +++ b/frontend/src/features/databases/ui/edit/EditDatabaseNotifiersComponent.tsx @@ -7,6 +7,7 @@ import { EditNotifierComponent } from '../../../notifiers/ui/edit/EditNotifierCo interface Props { database: Database; + workspaceId: string; isShowCancelButton?: boolean; onCancel: () => void; @@ -22,6 +23,7 @@ interface Props { export const EditDatabaseNotifiersComponent = ({ database, + workspaceId, isShowCancelButton, onCancel, @@ -65,7 +67,7 @@ export const EditDatabaseNotifiersComponent = ({ setIsNotifiersLoading(true); try { - const notifiers = await notifierApi.getNotifiers(); + const notifiers = await notifierApi.getNotifiers(workspaceId); setNotifiers(notifiers); } catch (e) { alert((e as Error).message); @@ -165,6 +167,7 @@ export const EditDatabaseNotifiersComponent = ({
setShowCreateNotifier(false)} diff --git a/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx b/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx index 8ed9af9..fef61bf 100644 --- a/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx +++ b/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx @@ -5,7 +5,6 @@ import { useEffect, useState } from 'react'; import { type Database, DatabaseType, - type PostgresqlDatabase, PostgresqlVersion, databaseApi, } from '../../../../entity/databases'; @@ -106,27 +105,6 @@ export const EditDatabaseSpecificDataComponent = ({ return (
-
-
Database type
- + +
Password
+ { + setPasswordError(false); + setPassword(e.currentTarget.value); + }} + status={passwordError ? 'error' : undefined} + iconRender={(visible) => (visible ? : )} + visibilityToggle={{ visible: passwordVisible, onVisibleChange: setPasswordVisible }} + /> + +
Confirm password
+ { + setConfirmPasswordError(false); + setConfirmPassword(e.currentTarget.value); + }} + iconRender={(visible) => (visible ? : )} + visibilityToggle={{ + visible: confirmPasswordVisible, + onVisibleChange: setConfirmPasswordVisible, + }} + /> + +
+ + + + {adminPasswordError && ( +
+ {adminPasswordError} +
+ )} +
+ ); +} diff --git a/frontend/src/features/users/ui/AuthNavbarComponent.tsx b/frontend/src/features/users/ui/AuthNavbarComponent.tsx index 002a20d..e71a1cb 100644 --- a/frontend/src/features/users/ui/AuthNavbarComponent.tsx +++ b/frontend/src/features/users/ui/AuthNavbarComponent.tsx @@ -2,23 +2,23 @@ import GitHubButton from 'react-github-btn'; export function AuthNavbarComponent() { return ( -
+
-
+
@@ -31,7 +31,7 @@ export function AuthNavbarComponent() { data-icon="octicon-star" data-size="large" data-show-count="true" - aria-label="Star RostislavDugin/postgresus on GitHub" + aria-label="Star Postgresus on GitHub" >  Star on GitHub diff --git a/frontend/src/features/users/ui/OauthComponent.tsx b/frontend/src/features/users/ui/OauthComponent.tsx new file mode 100644 index 0000000..a80e97c --- /dev/null +++ b/frontend/src/features/users/ui/OauthComponent.tsx @@ -0,0 +1,96 @@ +import { GithubOutlined, GoogleOutlined } from '@ant-design/icons'; +import { Button, message } from 'antd'; + +import { + GITHUB_CLIENT_ID, + GOOGLE_CLIENT_ID, + getOAuthRedirectUri, + isOAuthEnabled, +} from '../../../constants'; + +export function OauthComponent() { + if (!isOAuthEnabled()) { + return null; + } + + const redirectUri = getOAuthRedirectUri(); + + const handleGitHubLogin = () => { + if (!GITHUB_CLIENT_ID) { + message.error('GitHub OAuth is not configured'); + return; + } + + try { + const params = new URLSearchParams({ + client_id: GITHUB_CLIENT_ID, + redirect_uri: redirectUri, + state: 'github', + scope: 'user:email', + }); + + const githubAuthUrl = `https://github.com/login/oauth/authorize?${params.toString()}`; + + // Validate URL is properly formed + new URL(githubAuthUrl); + window.location.href = githubAuthUrl; + } catch (error) { + message.error('Invalid OAuth configuration'); + console.error('GitHub OAuth URL error:', error); + } + }; + + const handleGoogleLogin = () => { + if (!GOOGLE_CLIENT_ID) { + message.error('Google OAuth is not configured'); + return; + } + + try { + const params = new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state: 'google', + }); + + const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; + + // Validate URL is properly formed + new URL(googleAuthUrl); + window.location.href = googleAuthUrl; + } catch (error) { + message.error('Invalid OAuth configuration'); + console.error('Google OAuth URL error:', error); + } + }; + + return ( +
+
+ {GITHUB_CLIENT_ID && ( + + )} + + {GOOGLE_CLIENT_ID && ( + + )} +
+
+ ); +} diff --git a/frontend/src/features/users/ui/ProfileComponent.tsx b/frontend/src/features/users/ui/ProfileComponent.tsx new file mode 100644 index 0000000..24d5b27 --- /dev/null +++ b/frontend/src/features/users/ui/ProfileComponent.tsx @@ -0,0 +1,341 @@ +import { EyeInvisibleOutlined, EyeTwoTone, LoadingOutlined } from '@ant-design/icons'; +import { App, Button, Input, Spin } from 'antd'; +import { useEffect, useState } from 'react'; + +import { userApi } from '../../../entity/users/api/userApi'; +import type { ChangePasswordRequest } from '../../../entity/users/model/ChangePasswordRequest'; +import type { SignInRequest } from '../../../entity/users/model/SignInRequest'; +import type { UpdateUserInfoRequest } from '../../../entity/users/model/UpdateUserInfoRequest'; +import type { UserProfile } from '../../../entity/users/model/UserProfile'; +import { UserRole } from '../../../entity/users/model/UserRole'; + +interface Props { + contentHeight: number; +} + +const getRoleDisplayText = (role: UserRole): string => { + switch (role) { + case UserRole.ADMIN: + return 'Admin'; + case UserRole.MEMBER: + return 'Member'; + default: + return role; + } +}; + +export function ProfileComponent({ contentHeight }: Props) { + const { message } = App.useApp(); + const [user, setUser] = useState(undefined); + const [isChangingPassword, setIsChangingPassword] = useState(false); + + // Profile edit state + const [editName, setEditName] = useState(''); + const [editEmail, setEditEmail] = useState(''); + const [isUpdatingProfile, setIsUpdatingProfile] = useState(false); + const [editNameError, setEditNameError] = useState(false); + const [editEmailError, setEditEmailError] = useState(false); + + // Password change form state + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [newPasswordVisible, setNewPasswordVisible] = useState(false); + const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false); + + // Error states + const [newPasswordError, setNewPasswordError] = useState(false); + const [confirmPasswordError, setConfirmPasswordError] = useState(false); + + useEffect(() => { + loadUserProfile(); + }, []); + + const loadUserProfile = () => { + userApi + .getCurrentUser() + .then((user) => { + setUser(user); + setEditName(user.name); + setEditEmail(user.email); + }) + .catch((error) => { + message.error(error.message); + }); + }; + + const validatePasswordFields = (): boolean => { + let isValid = true; + + if (!newPassword) { + setNewPasswordError(true); + isValid = false; + } else if (newPassword.length < 6) { + setNewPasswordError(true); + message.error('Password must be at least 6 characters long'); + isValid = false; + } else { + setNewPasswordError(false); + } + + if (!confirmPassword) { + setConfirmPasswordError(true); + isValid = false; + } else if (newPassword !== confirmPassword) { + setConfirmPasswordError(true); + message.error('New passwords do not match'); + isValid = false; + } else { + setConfirmPasswordError(false); + } + + return isValid; + }; + + const handlePasswordChange = async () => { + if (!validatePasswordFields()) { + return; + } + + setIsChangingPassword(true); + + try { + const request: ChangePasswordRequest = { + newPassword, + }; + + await userApi.changePassword(request); + + // Reset form fields + setNewPassword(''); + setConfirmPassword(''); + + // Sign in again with new password + if (user?.email) { + try { + const signInRequest: SignInRequest = { + email: user.email, + password: newPassword, + }; + await userApi.signIn(signInRequest); + message.success('Successfully signed in with new password'); + } catch (signInError: unknown) { + const errorMessage = + signInError instanceof Error + ? signInError.message + : 'Failed to sign in with new password'; + message.error(errorMessage); + // If sign in fails, logout and redirect to login page + userApi.logout(); + userApi.notifyAuthListeners(); + window.location.reload(); + } + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Failed to change password'; + message.error(errorMessage); + } finally { + setIsChangingPassword(false); + } + }; + + const handleProfileUpdate = async () => { + // Validate name + if (!editName || editName.trim() === '') { + setEditNameError(true); + message.error('Name is required'); + return; + } + setEditNameError(false); + + // Validate email (only if not admin) + if (user?.email !== 'admin') { + if (!editEmail || editEmail.trim() === '') { + setEditEmailError(true); + message.error('Email is required'); + return; + } + setEditEmailError(false); + } + + setIsUpdatingProfile(true); + + try { + const request: UpdateUserInfoRequest = {}; + + // Only include fields that changed + if (editName !== user?.name) { + request.name = editName; + } + // Only include email if not admin and changed + if (user?.email !== 'admin' && editEmail !== user?.email) { + request.email = editEmail; + } + + // If nothing changed, just show a message + if (Object.keys(request).length === 0) { + message.info('No changes to save'); + setIsUpdatingProfile(false); + return; + } + + await userApi.updateUserInfo(request); + message.success('Profile updated successfully'); + + // Reload user profile + loadUserProfile(); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Failed to update profile'; + message.error(errorMessage); + } finally { + setIsUpdatingProfile(false); + } + }; + + const handleLogout = () => { + userApi.logout(); + window.location.reload(); + }; + + return ( +
+
+
+

Profile

+ +
+ {user ? ( + <> +
+

Profile Information

+
+
User ID
+
{user.id}
+ +
Name
+ { + setEditNameError(false); + setEditName(e.currentTarget.value); + }} + status={editNameError ? 'error' : undefined} + placeholder="Enter your name" + className="mb-4" + /> + +
Email
+ { + setEditEmailError(false); + setEditEmail(e.currentTarget.value.trim().toLowerCase()); + }} + status={editEmailError ? 'error' : undefined} + placeholder="Enter your email" + type="email" + className="mb-4" + disabled={user.email === 'admin'} + /> + {user.email === 'admin' && ( +
+ Admin email cannot be changed +
+ )} + +
Role
+
+ + {getRoleDisplayText(user.role)} + +
+ + {(editName !== user.name || editEmail !== user.email) && ( + + )} +
+
+ +
+ +
+ +
+

Change Password

+ +
+
New Password
+ { + setNewPasswordError(false); + setNewPassword(e.currentTarget.value); + }} + status={newPasswordError ? 'error' : undefined} + iconRender={(visible) => + visible ? : + } + visibilityToggle={{ + visible: newPasswordVisible, + onVisibleChange: setNewPasswordVisible, + }} + /> + +
Confirm New Password
+ { + setConfirmPasswordError(false); + setConfirmPassword(e.currentTarget.value); + }} + status={confirmPasswordError ? 'error' : undefined} + iconRender={(visible) => + visible ? : + } + visibilityToggle={{ + visible: confirmPasswordVisible, + onVisibleChange: setConfirmPasswordVisible, + }} + /> + +
+ + {(newPassword || confirmPassword) && ( + + )} +
+
+ + ) : ( +
+ } /> +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/features/users/ui/SignInComponent.tsx b/frontend/src/features/users/ui/SignInComponent.tsx index b102fee..5081cc8 100644 --- a/frontend/src/features/users/ui/SignInComponent.tsx +++ b/frontend/src/features/users/ui/SignInComponent.tsx @@ -2,10 +2,17 @@ import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons'; import { Button, Input } from 'antd'; import { type JSX, useState } from 'react'; +import { IS_CLOUD } from '../../../constants'; import { userApi } from '../../../entity/users'; +import { StringUtils } from '../../../shared/lib'; import { FormValidator } from '../../../shared/lib/FormValidator'; +import { OauthComponent } from './OauthComponent'; -export function SignInComponent(): JSX.Element { +interface SignInComponentProps { + onSwitchToSignUp?: () => void; +} + +export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX.Element { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [passwordVisible, setPasswordVisible] = useState(false); @@ -23,7 +30,7 @@ export function SignInComponent(): JSX.Element { return false; } - if (!FormValidator.isValidEmail(email)) { + if (!FormValidator.isValidEmail(email) && email !== 'admin') { setEmailError(true); return false; } @@ -49,7 +56,7 @@ export function SignInComponent(): JSX.Element { password, }); } catch (e) { - setSignInError((e as Error).message); + setSignInError(StringUtils.capitalizeFirstLetter((e as Error).message)); } setLoading(false); @@ -60,6 +67,19 @@ export function SignInComponent(): JSX.Element {
Sign in
+ + + {IS_CLOUD && ( +
+
+
+
+
+ or continue +
+
+ )} +
Your email
)} + + {onSwitchToSignUp && ( +
+ Don't have an account?{' '} + +
+ )}
); } diff --git a/frontend/src/features/users/ui/SignUpComponent.tsx b/frontend/src/features/users/ui/SignUpComponent.tsx index f77767d..74cc915 100644 --- a/frontend/src/features/users/ui/SignUpComponent.tsx +++ b/frontend/src/features/users/ui/SignUpComponent.tsx @@ -1,11 +1,20 @@ import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons'; -import { Button, Input } from 'antd'; +import { App, Button, Input } from 'antd'; import { type JSX, useState } from 'react'; +import { IS_CLOUD } from '../../../constants'; import { userApi } from '../../../entity/users'; +import { StringUtils } from '../../../shared/lib'; import { FormValidator } from '../../../shared/lib/FormValidator'; +import { OauthComponent } from './OauthComponent'; -export function SignUpComponent(): JSX.Element { +interface SignUpComponentProps { + onSwitchToSignIn?: () => void; +} + +export function SignUpComponent({ onSwitchToSignIn }: SignUpComponentProps): JSX.Element { + const { message } = App.useApp(); + const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [passwordVisible, setPasswordVisible] = useState(false); @@ -14,6 +23,7 @@ export function SignUpComponent(): JSX.Element { const [isLoading, setLoading] = useState(false); + const [nameError, setNameError] = useState(false); const [isEmailError, setEmailError] = useState(false); const [passwordError, setPasswordError] = useState(false); const [confirmPasswordError, setConfirmPasswordError] = useState(false); @@ -21,6 +31,13 @@ export function SignUpComponent(): JSX.Element { const [signUpError, setSignUpError] = useState(''); const validateFieldsForSignUp = (): boolean => { + if (!name || name.trim() === '') { + setNameError(true); + message.error('Name is required'); + return false; + } + setNameError(false); + if (!email) { setEmailError(true); return false; @@ -38,7 +55,7 @@ export function SignUpComponent(): JSX.Element { if (password.length < 8) { setPasswordError(true); - alert('Password must be at least 8 characters long'); + message.error('Password must be at least 8 characters long'); return false; } setPasswordError(false); @@ -66,10 +83,11 @@ export function SignUpComponent(): JSX.Element { await userApi.signUp({ email, password, + name, }); await userApi.signIn({ email, password }); } catch (e) { - setSignUpError((e as Error).message); + setSignUpError(StringUtils.capitalizeFirstLetter((e as Error).message)); } } @@ -80,6 +98,30 @@ export function SignUpComponent(): JSX.Element {
Sign up
+ + + {IS_CLOUD && ( +
+
+
+
+
+ or continue +
+
+ )} + +
Your name
+ { + setNameError(false); + setName(e.currentTarget.value); + }} + status={nameError ? 'error' : undefined} + /> +
Your email
)} + + {onSwitchToSignIn && ( +
+ Already have an account?{' '} + +
+ )}
); } diff --git a/frontend/src/features/users/ui/UserAuditLogsSidebarComponent.tsx b/frontend/src/features/users/ui/UserAuditLogsSidebarComponent.tsx new file mode 100644 index 0000000..778dda0 --- /dev/null +++ b/frontend/src/features/users/ui/UserAuditLogsSidebarComponent.tsx @@ -0,0 +1,189 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { App, Spin, Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { auditLogApi } from '../../../entity/audit-logs/api/auditLogApi'; +import type { AuditLog } from '../../../entity/audit-logs/model/AuditLog'; +import type { GetAuditLogsRequest } from '../../../entity/audit-logs/model/GetAuditLogsRequest'; +import type { UserProfile } from '../../../entity/users/model/UserProfile'; +import { getUserTimeFormat } from '../../../shared/time'; + +interface Props { + user: UserProfile; +} + +export function UserAuditLogsSidebarComponent({ user }: Props) { + const { message } = App.useApp(); + const [auditLogs, setAuditLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [total, setTotal] = useState(0); + + const pageSize = 50; + + const scrollContainerRef = useRef(null); + const loadingRef = useRef(false); + + useEffect(() => { + loadAuditLogs(true); + }, [user.id]); + + const handleScroll = useCallback(() => { + if (!scrollContainerRef.current || isLoadingMore || !hasMore || loadingRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + const threshold = 100; + + if (scrollHeight - scrollTop - clientHeight < threshold) { + loadAuditLogs(false); + } + }, [isLoadingMore, hasMore]); + + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (scrollContainer) { + scrollContainer.addEventListener('scroll', handleScroll); + return () => scrollContainer.removeEventListener('scroll', handleScroll); + } + }, [handleScroll]); + + const loadAuditLogs = async (isInitialLoad = false) => { + if (!isInitialLoad && loadingRef.current) { + return; + } + + loadingRef.current = true; + + if (isInitialLoad) { + setIsLoading(true); + setAuditLogs([]); + } else { + setIsLoadingMore(true); + } + + try { + const offset = isInitialLoad ? 0 : auditLogs.length; + const request: GetAuditLogsRequest = { + limit: pageSize, + offset: offset, + }; + + const response = await auditLogApi.getUserAuditLogs(user.id, request); + + if (isInitialLoad) { + setAuditLogs(response.auditLogs); + } else { + setAuditLogs((prev) => { + const existingIds = new Set(prev.map((log) => log.id)); + const newLogs = response.auditLogs.filter((log) => !existingIds.has(log.id)); + return [...prev, ...newLogs]; + }); + } + + setTotal(response.total); + setHasMore(response.auditLogs.length === pageSize); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load audit logs'; + message.error(errorMessage); + } finally { + loadingRef.current = false; + setIsLoading(false); + setIsLoadingMore(false); + } + }; + + const columns: ColumnsType = [ + { + title: 'Message', + dataIndex: 'message', + key: 'message', + width: 350, + render: (message: string) => {message}, + }, + { + title: 'Workspace', + dataIndex: 'workspaceName', + key: 'workspaceName', + width: 200, + render: (workspaceId: string | undefined) => ( + + {workspaceId || '-'} + + ), + }, + { + title: 'Created', + dataIndex: 'createdAt', + key: 'createdAt', + width: 200, + render: (createdAt: string) => { + const date = dayjs(createdAt); + const timeFormat = getUserTimeFormat(); + return ( + + {`${date.format(timeFormat.format)} (${date.fromNow()})`} + + ); + }, + }, + ]; + + return ( +
+
+
+
+ {isLoading ? ( + } /> + ) : ( + `${auditLogs.length} of ${total} logs` + )} +
+
+ + {isLoading ? ( +
+ } size="large" /> +
+ ) : ( + <> + + + {isLoadingMore && ( +
+ } /> + Loading more logs... +
+ )} + + {!hasMore && auditLogs.length > 0 && ( +
+ All logs loaded ({total} total) +
+ )} + + {!isLoading && auditLogs.length === 0 && ( +
+ No audit logs found for this user. +
+ )} + + )} + + + ); +} diff --git a/frontend/src/features/users/ui/UsersComponent.tsx b/frontend/src/features/users/ui/UsersComponent.tsx new file mode 100644 index 0000000..dc19c70 --- /dev/null +++ b/frontend/src/features/users/ui/UsersComponent.tsx @@ -0,0 +1,366 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { App, Button, Drawer, Input, Select, Spin, Switch, Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { userManagementApi } from '../../../entity/users/api/userManagementApi'; +import type { ChangeUserRoleRequest } from '../../../entity/users/model/ChangeUserRoleRequest'; +import type { ListUsersRequest } from '../../../entity/users/model/ListUsersRequest'; +import type { UserProfile } from '../../../entity/users/model/UserProfile'; +import { UserRole } from '../../../entity/users/model/UserRole'; +import { getUserTimeFormat } from '../../../shared/time'; +import { UserAuditLogsSidebarComponent } from './UserAuditLogsSidebarComponent'; + +interface Props { + contentHeight: number; +} + +const getRoleColor = (role: UserRole): string => { + switch (role) { + case UserRole.ADMIN: + return '#3b82f6'; + case UserRole.MEMBER: + return '#10b981'; + default: + return '#6b7280'; + } +}; + +export function UsersComponent({ contentHeight }: Props) { + const { message } = App.useApp(); + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [total, setTotal] = useState(0); + const [searchQuery, setSearchQuery] = useState(''); + const [inputValue, setInputValue] = useState(''); + + const pageSize = 20; + + const [processingUsers, setProcessingUsers] = useState>(new Set()); + const [changingRoleUsers, setChangingRoleUsers] = useState>(new Set()); + + const [selectedUser, setSelectedUser] = useState(null); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const scrollContainerRef = useRef(null); + const loadingRef = useRef(false); + + useEffect(() => { + loadUsers(true); + }, []); + + useEffect(() => { + const timer = setTimeout(() => { + if (inputValue !== searchQuery) { + setSearchQuery(inputValue); + setHasMore(true); + loadUsers(true, inputValue); + } + }, 500); + + return () => clearTimeout(timer); + }, [inputValue]); + + const handleScroll = useCallback(() => { + if (!scrollContainerRef.current || isLoadingMore || !hasMore || loadingRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + const threshold = 100; + + if (scrollHeight - scrollTop - clientHeight < threshold) { + loadUsers(false); + } + }, [isLoadingMore, hasMore]); + + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (scrollContainer) { + scrollContainer.addEventListener('scroll', handleScroll); + return () => scrollContainer.removeEventListener('scroll', handleScroll); + } + }, [handleScroll]); + + const loadUsers = async (isInitialLoad = false, query?: string) => { + if (!isInitialLoad && loadingRef.current) { + return; + } + + loadingRef.current = true; + + if (isInitialLoad) { + setIsLoading(true); + setUsers([]); + } else { + setIsLoadingMore(true); + } + + try { + const offset = isInitialLoad ? 0 : users.length; + const currentQuery = query !== undefined ? query : searchQuery; + const request: ListUsersRequest = { + limit: pageSize, + offset: offset, + query: currentQuery || undefined, + }; + + const response = await userManagementApi.getUsers(request); + + if (isInitialLoad) { + setUsers(response.users); + } else { + setUsers((prev) => { + const existingIds = new Set(prev.map((user) => user.id)); + const newUsers = response.users.filter((user) => !existingIds.has(user.id)); + return [...prev, ...newUsers]; + }); + } + + setTotal(response.total); + setHasMore(response.users.length === pageSize); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load users'; + message.error(errorMessage); + } finally { + loadingRef.current = false; + setIsLoading(false); + setIsLoadingMore(false); + } + }; + + const handleActivationToggle = async (userId: string, isActive: boolean) => { + setUsers((prev) => + prev.map((user) => (user.id === userId ? { ...user, isActive: !isActive } : user)), + ); + + setProcessingUsers((prev) => new Set(prev).add(userId)); + + try { + if (isActive) { + await userManagementApi.deactivateUser(userId); + message.success('User deactivated successfully'); + } else { + await userManagementApi.activateUser(userId); + message.success('User activated successfully'); + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Operation failed'; + message.error(errorMessage); + + setUsers((prev) => + prev.map((user) => (user.id === userId ? { ...user, isActive: isActive } : user)), + ); + } finally { + setProcessingUsers((prev) => { + const newSet = new Set(prev); + newSet.delete(userId); + return newSet; + }); + } + }; + + const handleRoleChange = async (userId: string, newRole: UserRole) => { + const currentUser = users.find((user) => user.id === userId); + const originalRole = currentUser?.role; + + setUsers((prev) => + prev.map((user) => (user.id === userId ? { ...user, role: newRole } : user)), + ); + + setChangingRoleUsers((prev) => new Set(prev).add(userId)); + + try { + const request: ChangeUserRoleRequest = { role: newRole }; + await userManagementApi.changeUserRole(userId, request); + message.success('User role changed successfully'); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Failed to change user role'; + message.error(errorMessage); + + if (originalRole) { + setUsers((prev) => + prev.map((user) => (user.id === userId ? { ...user, role: originalRole } : user)), + ); + } + } finally { + setChangingRoleUsers((prev) => { + const newSet = new Set(prev); + newSet.delete(userId); + return newSet; + }); + } + }; + + const handleRowClick = (user: UserProfile) => { + setSelectedUser(user); + setIsDrawerOpen(true); + }; + + const handleDrawerClose = () => { + setIsDrawerOpen(false); + setSelectedUser(null); + }; + + const columns: ColumnsType = [ + { + title: 'User', + key: 'user', + width: 350, + render: (_, record: UserProfile) => ( +
+ {record.name} ({record.email}) +
+ ), + }, + { + title: 'System role', + dataIndex: 'role', + key: 'role', + width: 200, + render: (role: UserRole, record: UserProfile) => ( + setInputValue(e.target.value)} + style={{ width: 400 }} + /> + + + {isLoading ? ( +
+ } size="large" /> +
+ ) : ( + <> +
+ + {isLoadingMore && ( +
+ } /> +
+ )} + + {!hasMore && users.length > 0 && ( +
+ All users loaded ({total} total) +
+ )} + + )} + + + + {/* Audit logs drawer */} + +
User Audit Logs
+
{selectedUser?.email}
+ + } + placement="right" + width={900} + onClose={handleDrawerClose} + open={isDrawerOpen} + > + {selectedUser && } +
+ + ); +} diff --git a/frontend/src/features/workspaces/index.ts b/frontend/src/features/workspaces/index.ts new file mode 100644 index 0000000..85efae8 --- /dev/null +++ b/frontend/src/features/workspaces/index.ts @@ -0,0 +1,4 @@ +export { CreateWorkspaceDialogComponent } from './ui/CreateWorkspaceDialogComponent'; +export { WorkspaceSettingsComponent } from './ui/WorkspaceSettingsComponent'; +export { WorkspaceMembershipComponent } from './ui/WorkspaceMembershipComponent'; +export { WorkspaceAuditLogsComponent } from './ui/WorkspaceAuditLogsComponent'; diff --git a/frontend/src/features/workspaces/ui/CreateWorkspaceDialogComponent.tsx b/frontend/src/features/workspaces/ui/CreateWorkspaceDialogComponent.tsx new file mode 100644 index 0000000..47539fe --- /dev/null +++ b/frontend/src/features/workspaces/ui/CreateWorkspaceDialogComponent.tsx @@ -0,0 +1,126 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { App, Button, Input, Modal } from 'antd'; +import { Spin } from 'antd'; +import { useState } from 'react'; + +import { type UserProfile, UserRole, type UsersSettings } from '../../../entity/users'; +import type { WorkspaceResponse } from '../../../entity/workspaces'; +import { workspaceApi } from '../../../entity/workspaces'; + +interface Props { + user: UserProfile; + globalSettings: UsersSettings; + + onClose: () => void; + onWorkspaceCreated: (workspace: WorkspaceResponse) => void; + + workspacesCount: number; +} + +export const CreateWorkspaceDialogComponent = ({ + user, + globalSettings, + onClose, + onWorkspaceCreated, + workspacesCount, +}: Props) => { + const { message } = App.useApp(); + const [isCreating, setIsCreating] = useState(false); + const [workspaceName, setWorkspaceName] = useState(workspacesCount === 0 ? 'My workspace' : ''); + + const isAllowedToCreateWorkspaces = + globalSettings.isMemberAllowedToCreateWorkspaces || user.role === UserRole.ADMIN; + + const handleCreateWorkspace = async () => { + if (!workspaceName.trim()) { + message.error('Please enter a workspace name'); + return; + } + + setIsCreating(true); + + try { + const newWorkspace = await workspaceApi.createWorkspace({ + name: workspaceName.trim(), + }); + + message.success('Workspace created successfully'); + onWorkspaceCreated(newWorkspace); + onClose(); + } catch (error) { + message.error((error as Error).message || 'Failed to create workspace'); + } finally { + setIsCreating(false); + } + }; + + if (!isAllowedToCreateWorkspaces) { + return ( + + OK + , + ]} + > +

+ You don't have permission to create workspaces. Please ask the administrator to + create the workspace for you. +

+
+ ); + } + + return ( + + Cancel + , + + , + ]} + > +
+
+ Workspace is a place where you group: +
+ - your databases; +
+ - storages (like local drive, S3, Google Drive, etc.) +
+ - notifiers (like email, Slack, Telegram, etc.); +
- access control (if you have team); +
+ + + setWorkspaceName(e.target.value)} + placeholder="Enter workspace name" + disabled={isCreating} + onPressEnter={handleCreateWorkspace} + autoFocus + /> +
+
+ ); +}; diff --git a/frontend/src/features/workspaces/ui/WorkspaceAuditLogsComponent.tsx b/frontend/src/features/workspaces/ui/WorkspaceAuditLogsComponent.tsx new file mode 100644 index 0000000..fe0d745 --- /dev/null +++ b/frontend/src/features/workspaces/ui/WorkspaceAuditLogsComponent.tsx @@ -0,0 +1,204 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { App, Spin, Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { AuditLog } from '../../../entity/audit-logs/model/AuditLog'; +import { workspaceApi } from '../../../entity/workspaces/api/workspaceApi'; +import { getUserShortTimeFormat } from '../../../shared/time'; + +interface Props { + workspaceId: string; + scrollContainerRef?: React.RefObject; +} + +export function WorkspaceAuditLogsComponent({ + workspaceId, + scrollContainerRef: externalScrollRef, +}: Props) { + const { message } = App.useApp(); + const [auditLogs, setAuditLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [total, setTotal] = useState(0); + + const pageSize = 50; + + const internalScrollRef = useRef(null); + const scrollContainerRef = externalScrollRef || internalScrollRef; + const loadingRef = useRef(false); + + const handleScroll = useCallback(() => { + if (!scrollContainerRef.current || isLoadingMore || !hasMore || loadingRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + const threshold = 100; + + if (scrollHeight - scrollTop - clientHeight < threshold) { + loadAuditLogs(false); + } + }, [isLoadingMore, hasMore]); + + const loadAuditLogs = async (isInitialLoad = false) => { + if (!isInitialLoad && loadingRef.current) { + return; + } + + loadingRef.current = true; + + if (isInitialLoad) { + setIsLoading(true); + setAuditLogs([]); + } else { + setIsLoadingMore(true); + } + + try { + const offset = isInitialLoad ? 0 : auditLogs.length; + const params = { + limit: pageSize, + offset: offset, + }; + + const response = await workspaceApi.getWorkspaceAuditLogs(workspaceId, params); + + if (isInitialLoad) { + setAuditLogs(response.auditLogs); + } else { + setAuditLogs((prev) => { + const existingIds = new Set(prev.map((log) => log.id)); + const newLogs = response.auditLogs.filter((log) => !existingIds.has(log.id)); + return [...prev, ...newLogs]; + }); + } + + setTotal(response.total); + setHasMore(response.auditLogs.length === pageSize); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to load workspace audit logs'; + message.error(errorMessage); + } finally { + loadingRef.current = false; + setIsLoading(false); + setIsLoadingMore(false); + } + }; + + useEffect(() => { + if (workspaceId) { + loadAuditLogs(true); + } + }, [workspaceId]); + + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (scrollContainer) { + scrollContainer.addEventListener('scroll', handleScroll); + return () => scrollContainer.removeEventListener('scroll', handleScroll); + } + }, [handleScroll]); + + const columns: ColumnsType = [ + { + title: 'User', + key: 'user', + width: 300, + render: (_, record: AuditLog) => { + if (!record.userEmail && !record.userName) { + return ( + + System + + ); + } + + const displayText = record.userName + ? `${record.userName} (${record.userEmail})` + : record.userEmail; + + return ( + + {displayText} + + ); + }, + }, + { + title: 'Message', + dataIndex: 'message', + key: 'message', + render: (message: string) => {message}, + }, + { + title: 'Created', + dataIndex: 'createdAt', + key: 'createdAt', + width: 250, + render: (createdAt: string) => { + const date = dayjs(createdAt); + const timeFormat = getUserShortTimeFormat(); + return ( + + {`${date.format(timeFormat.format)} (${date.fromNow()})`} + + ); + }, + }, + ]; + + if (!workspaceId) { + return null; + } + + return ( +
+
+

Audit logs

+
+ {isLoading ? ( + } /> + ) : ( + `${auditLogs.length} of ${total} logs` + )} +
+
+ + {isLoading ? ( +
+ } size="large" /> +
+ ) : auditLogs.length === 0 ? ( +
+ No audit logs found for this workspace. +
+ ) : ( + <> +
+ + {isLoadingMore && ( +
+ } /> + Loading more logs... +
+ )} + + {!hasMore && auditLogs.length > 0 && ( +
+ All logs loaded ({total} total) +
+ )} + + )} + + ); +} diff --git a/frontend/src/features/workspaces/ui/WorkspaceMembershipComponent.tsx b/frontend/src/features/workspaces/ui/WorkspaceMembershipComponent.tsx new file mode 100644 index 0000000..2258b96 --- /dev/null +++ b/frontend/src/features/workspaces/ui/WorkspaceMembershipComponent.tsx @@ -0,0 +1,651 @@ +import { + DeleteOutlined, + LoadingOutlined, + PlusOutlined, + SwapOutlined, + UserAddOutlined, + UserOutlined, +} from '@ant-design/icons'; +import { + App, + AutoComplete, + Button, + Input, + Modal, + Popconfirm, + Select, + Spin, + Table, + Tag, + Tooltip, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { useEffect, useState } from 'react'; + +import type { UserProfile } from '../../../entity/users'; +import { userManagementApi } from '../../../entity/users/api/userManagementApi'; +import { UserRole } from '../../../entity/users/model/UserRole'; +import { WorkspaceRole } from '../../../entity/users/model/WorkspaceRole'; +import type { + AddMemberRequest, + AddMemberResponse, + ChangeMemberRoleRequest, + GetMembersResponse, + TransferOwnershipRequest, + WorkspaceMemberResponse, + WorkspaceResponse, +} from '../../../entity/workspaces'; +import { AddMemberStatusEnum, workspaceMembershipApi } from '../../../entity/workspaces'; +import { StringUtils } from '../../../shared/lib'; +import { getUserShortTimeFormat } from '../../../shared/time'; + +interface Props { + workspaceResponse: WorkspaceResponse; + user: UserProfile; +} + +export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props) { + const { message } = App.useApp(); + + const [members, setMembers] = useState([]); + const [isLoadingMembers, setIsLoadingMembers] = useState(true); + + const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false); + const [addMemberForm, setAddMemberForm] = useState({ email: '', role: WorkspaceRole.MEMBER }); + const [isAddingMember, setIsAddingMember] = useState(false); + const [addMemberEmailError, setAddMemberEmailError] = useState(false); + + const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); + const [invitedEmail, setInvitedEmail] = useState(''); + + const [changingRoleFor, setChangingRoleFor] = useState(null); + const [isChangingRole, setIsChangingRole] = useState(false); + + const [isTransferOwnershipModalOpen, setIsTransferOwnershipModalOpen] = useState(false); + const [transferForm, setTransferForm] = useState({ selectedMemberId: '' }); + const [isTransferringOwnership, setIsTransferringOwnership] = useState(false); + const [transferMemberError, setTransferMemberError] = useState(false); + + const [removingMembers, setRemovingMembers] = useState>(new Set()); + + const [userSearchResults, setUserSearchResults] = useState([]); + const [isSearchingUsers, setIsSearchingUsers] = useState(false); + const [searchInputValue, setSearchInputValue] = useState(''); + + // Only OWNER and ADMIN can manage members + // MEMBER and VIEWER cannot manage members + const canManageMembers = + user.role === UserRole.ADMIN || + workspaceResponse.userRole === WorkspaceRole.OWNER || + workspaceResponse.userRole === WorkspaceRole.ADMIN; + + const canTransferOwnership = + user.role === UserRole.ADMIN || workspaceResponse.userRole === WorkspaceRole.OWNER; + + const eligibleMembers = members.filter((member) => { + if (member.role === WorkspaceRole.OWNER) return false; + + if (member.userId === user.id || member.email === user.email) { + return user.role === UserRole.ADMIN && workspaceResponse.userRole !== WorkspaceRole.OWNER; + } + + return true; + }); + + const loadMembers = async () => { + setIsLoadingMembers(true); + try { + const response: GetMembersResponse = await workspaceMembershipApi.getMembers( + workspaceResponse.id, + ); + setMembers(response.members); + } catch (error: unknown) { + const errorMessage = + error instanceof Error + ? StringUtils.capitalizeFirstLetter(error.message) + : 'Failed to load members'; + message.error(errorMessage); + } finally { + setIsLoadingMembers(false); + } + }; + + const searchUsers = async (query: string) => { + if (user.role !== UserRole.ADMIN) return; + + setIsSearchingUsers(true); + try { + const response = await userManagementApi.getUsers({ + limit: 10, + query: query || undefined, + }); + const activeUsers = response.users.filter((u) => u.isActive); + setUserSearchResults(activeUsers); + } catch (error: unknown) { + const errorMessage = + error instanceof Error + ? StringUtils.capitalizeFirstLetter(error.message) + : 'Failed to search users'; + message.error(errorMessage); + setUserSearchResults([]); + } finally { + setIsSearchingUsers(false); + } + }; + + useEffect(() => { + if (user.role !== UserRole.ADMIN || !isAddMemberModalOpen) return; + + const timer = setTimeout(() => { + searchUsers(searchInputValue); + }, 300); + + return () => clearTimeout(timer); + }, [searchInputValue, isAddMemberModalOpen]); + + const handleAddMember = async () => { + if (!addMemberForm.email.trim()) { + setAddMemberEmailError(true); + message.error('Email is required'); + return; + } + setAddMemberEmailError(false); + setIsAddingMember(true); + + try { + const request: AddMemberRequest = { + email: addMemberForm.email.trim(), + role: addMemberForm.role, + }; + const response: AddMemberResponse = await workspaceMembershipApi.addMember( + workspaceResponse.id, + request, + ); + + const emailToRemember = request.email; + setAddMemberForm({ email: '', role: WorkspaceRole.MEMBER }); + setIsAddMemberModalOpen(false); + + if (response.status === AddMemberStatusEnum.ADDED) { + message.success('Member added successfully'); + loadMembers(); + } else if (response.status === AddMemberStatusEnum.INVITED) { + setInvitedEmail(emailToRemember); + setIsInviteDialogOpen(true); + loadMembers(); + } + } catch (error: unknown) { + const errorMessage = + error instanceof Error + ? StringUtils.capitalizeFirstLetter(error.message) + : 'Failed to add member'; + message.error(errorMessage); + } finally { + setIsAddingMember(false); + } + }; + + const handleChangeRole = async (userId: string, newRole: WorkspaceRole) => { + setChangingRoleFor(userId); + setIsChangingRole(true); + + try { + const request: ChangeMemberRoleRequest = { role: newRole }; + await workspaceMembershipApi.changeMemberRole(workspaceResponse.id, userId, request); + + setMembers((prev) => + prev.map((member) => (member.userId === userId ? { ...member, role: newRole } : member)), + ); + + message.success('Member role updated successfully'); + } catch (error: unknown) { + const errorMessage = + error instanceof Error + ? StringUtils.capitalizeFirstLetter(error.message) + : 'Failed to change member role'; + message.error(errorMessage); + } finally { + setChangingRoleFor(null); + setIsChangingRole(false); + } + }; + + const handleRemoveMember = async (userId: string, memberEmail: string) => { + setRemovingMembers((prev) => new Set(prev).add(userId)); + + try { + await workspaceMembershipApi.removeMember(workspaceResponse.id, userId); + setMembers((prev) => prev.filter((member) => member.userId !== userId)); + message.success(`Member "${memberEmail}" removed successfully`); + } catch (error: unknown) { + const errorMessage = + error instanceof Error + ? StringUtils.capitalizeFirstLetter(error.message) + : 'Failed to remove member'; + message.error(errorMessage); + } finally { + setRemovingMembers((prev) => { + const newSet = new Set(prev); + newSet.delete(userId); + return newSet; + }); + } + }; + + const handleTransferOwnership = async () => { + if (!transferForm.selectedMemberId) { + setTransferMemberError(true); + message.error('Please select a member to transfer ownership to'); + return; + } + + const selectedMember = members.find( + (member) => member.userId === transferForm.selectedMemberId, + ); + if (!selectedMember) { + message.error('Selected member not found'); + return; + } + + setTransferMemberError(false); + setIsTransferringOwnership(true); + + try { + const request: TransferOwnershipRequest = { + newOwnerEmail: selectedMember.email, + }; + await workspaceMembershipApi.transferOwnership(workspaceResponse.id, request); + + setTransferForm({ selectedMemberId: '' }); + setIsTransferOwnershipModalOpen(false); + message.success('Ownership transferred successfully'); + loadMembers(); + } catch (error: unknown) { + const errorMessage = + error instanceof Error + ? StringUtils.capitalizeFirstLetter(error.message) + : 'Failed to transfer ownership'; + message.error(errorMessage); + } finally { + setIsTransferringOwnership(false); + } + }; + + const getRoleColor = (role: WorkspaceRole): string => { + switch (role) { + case WorkspaceRole.OWNER: + return 'purple'; + case WorkspaceRole.ADMIN: + return 'orange'; + case WorkspaceRole.MEMBER: + return 'blue'; + case WorkspaceRole.VIEWER: + return 'green'; + default: + return 'default'; + } + }; + + const getRoleDisplayText = (role: WorkspaceRole): string => { + switch (role) { + case WorkspaceRole.OWNER: + return 'Owner'; + case WorkspaceRole.ADMIN: + return 'Admin'; + case WorkspaceRole.MEMBER: + return 'Member'; + case WorkspaceRole.VIEWER: + return 'Viewer'; + default: + return role; + } + }; + + useEffect(() => { + loadMembers(); + }, [workspaceResponse.id]); + + const columns: ColumnsType = [ + { + title: 'Member', + key: 'member', + width: 300, + render: (_, record: WorkspaceMemberResponse) => ( +
+ +
+
{record.name}
+
{record.email}
+
+
+ ), + }, + { + title: 'Role', + dataIndex: 'role', + key: 'role', + width: 150, + render: (role: WorkspaceRole, record: WorkspaceMemberResponse) => { + const isCurrentUser = record.userId === user.id || record.email === user.email; + + if (canManageMembers && role !== WorkspaceRole.OWNER && !isCurrentUser) { + return ( +
+
No members found
+ {canManageMembers && ( +
Click "Add member" to get started
+ )} + + ), + }} + /> + + )} + + {/* Add Member Modal */} + { + setIsAddMemberModalOpen(false); + setAddMemberForm({ email: '', role: WorkspaceRole.MEMBER }); + setAddMemberEmailError(false); + setSearchInputValue(''); + setUserSearchResults([]); + }} + confirmLoading={isAddingMember} + okText="Add member" + cancelText="Cancel" + okButtonProps={{ + className: 'border-blue-600 bg-blue-600 hover:border-blue-700 hover:bg-blue-700', + }} + > +
+
+
Email address
+ {user.role === UserRole.ADMIN ? ( + { + setAddMemberEmailError(false); + setAddMemberForm({ + ...addMemberForm, + email: value.toLowerCase().trim(), + }); + setSearchInputValue(value); + }} + onSelect={(value) => { + setAddMemberForm({ + ...addMemberForm, + email: value.toLowerCase().trim(), + }); + }} + onFocus={() => { + searchUsers(''); + }} + placeholder="Enter email address" + status={addMemberEmailError ? 'error' : undefined} + options={userSearchResults.map((user) => ({ + value: user.email, + label: `${user.name} (${user.email})`, + }))} + notFoundContent={ + isSearchingUsers ? ( +
+ } size="small" /> +
+ ) : null + } + style={{ width: '100%' }} + /> + ) : ( + { + setAddMemberEmailError(false); + setAddMemberForm({ + ...addMemberForm, + email: e.target.value.toLowerCase().trim(), + }); + }} + placeholder="Enter email address" + status={addMemberEmailError ? 'error' : undefined} + /> + )} +
+ If the user exists, they will be added directly. Otherwise, an invitation will be + sent. +
+
+ +
+
Role
+ { + setTransferMemberError(false); + setTransferForm({ selectedMemberId: memberId }); + }} + placeholder="Select a member to transfer ownership to" + style={{ width: '100%' }} + status={transferMemberError ? 'error' : undefined} + options={eligibleMembers.map((member) => ({ + label: ( +
+ +
+ {member.name} ({member.email}) +
+
+ ), + value: member.userId, + }))} + /> +
+ The selected member will become the workspace owner +
+
+ )} +
+
+ + ); +} diff --git a/frontend/src/features/workspaces/ui/WorkspaceSettingsComponent.tsx b/frontend/src/features/workspaces/ui/WorkspaceSettingsComponent.tsx new file mode 100644 index 0000000..486d55c --- /dev/null +++ b/frontend/src/features/workspaces/ui/WorkspaceSettingsComponent.tsx @@ -0,0 +1,280 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { App, Button, Input, Spin } from 'antd'; +import { useEffect, useRef, useState } from 'react'; + +import { databaseApi } from '../../../entity/databases/api/databaseApi'; +import type { UserProfile } from '../../../entity/users/model/UserProfile'; +import { UserRole } from '../../../entity/users/model/UserRole'; +import { WorkspaceRole } from '../../../entity/users/model/WorkspaceRole'; +import { workspaceApi } from '../../../entity/workspaces/api/workspaceApi'; +import type { Workspace } from '../../../entity/workspaces/model/Workspace'; +import type { WorkspaceResponse } from '../../../entity/workspaces/model/WorkspaceResponse'; +import { WorkspaceAuditLogsComponent } from './WorkspaceAuditLogsComponent'; +import { WorkspaceMembershipComponent } from './WorkspaceMembershipComponent'; + +interface Props { + workspaceResponse: WorkspaceResponse; + user: UserProfile; + contentHeight: number; +} + +export function WorkspaceSettingsComponent({ workspaceResponse, user, contentHeight }: Props) { + const { message, modal } = App.useApp(); + const [workspace, setWorkspace] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const scrollContainerRef = useRef(null); + + const [formWorkspace, setFormWorkspace] = useState>({}); + const [nameError, setNameError] = useState(false); + + const [basicInfoChanges, setBasicInfoChanges] = useState(false); + + // Only OWNER and ADMIN can edit workspace settings + // MEMBER and VIEWER can only view + const canEdit = + user.role === UserRole.ADMIN || + workspaceResponse.userRole === WorkspaceRole.OWNER || + workspaceResponse.userRole === WorkspaceRole.ADMIN; + + useEffect(() => { + loadWorkspace(); + }, [workspaceResponse.id]); + + const checkBasicInfoChanges = (newFormWorkspace: Partial): boolean => { + if (!workspace) return false; + return newFormWorkspace.name !== workspace.name; + }; + + const loadWorkspace = async () => { + setIsLoading(true); + + try { + const workspaceData = await workspaceApi.getWorkspace(workspaceResponse.id); + setWorkspace(workspaceData); + setFormWorkspace(workspaceData); + setNameError(false); + + setBasicInfoChanges(false); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load workspace'; + message.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleFieldChange = (field: K, value: Workspace[K]) => { + const newFormWorkspace = { ...formWorkspace, [field]: value }; + setFormWorkspace(newFormWorkspace); + + if (workspace) { + setBasicInfoChanges(checkBasicInfoChanges(newFormWorkspace)); + } + }; + + const saveBasicInfo = async () => { + if (!basicInfoChanges || !workspace || !canEdit) return; + + if (!formWorkspace.name?.trim()) { + setNameError(true); + message.error('Workspace name is required'); + return; + } + setNameError(false); + + setIsSaving(true); + try { + const updateData = { + ...workspace, + name: formWorkspace.name, + }; + const updatedWorkspace = await workspaceApi.updateWorkspace(workspace.id, updateData); + setWorkspace(updatedWorkspace); + setFormWorkspace(updatedWorkspace); + + setBasicInfoChanges(false); + + setNameError(false); + message.success('Basic information updated successfully'); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to update basic information'; + message.error(errorMessage); + } finally { + setIsSaving(false); + } + }; + + const handleDeleteWorkspace = async () => { + if (!workspace) { + message.error('Workspace not found'); + return; + } + + if (!canEdit) { + message.error('You do not have permission to delete this workspace'); + return; + } + + modal.confirm({ + title: 'Delete Workspace', + content: ( +
+

+ Are you sure you want to delete the workspace {workspace.name}? +

+

+ This action cannot be undone. All data and associated resources will be + permanently removed. +

+
+ ), + okText: 'Delete Workspace', + okType: 'danger', + cancelText: 'Cancel', + onOk: async () => { + setIsDeleting(true); + try { + // Check if there are any databases in the workspace + const databases = await databaseApi.getDatabases(workspace.id); + + if (databases && databases.length > 0) { + message.error( + `Cannot delete workspace. Please remove all databases first. Found ${databases.length} database(s).`, + ); + return; + } + + await workspaceApi.deleteWorkspace(workspace.id); + message.success('Workspace deleted successfully'); + window.location.href = '/'; + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to delete workspace'; + message.error(errorMessage); + } finally { + setIsDeleting(false); + } + }, + }); + }; + + return ( +
+
+
+

Workspace Settings

+ + {isLoading || !workspace ? ( + } size="large" /> + ) : ( + <> + {!canEdit && ( +
+
+ You don't have permission to modify these settings +
+
+ )} + +
+
+
+
Workspace name
+ { + setNameError(false); + handleFieldChange('name', e.target.value); + }} + disabled={!canEdit} + placeholder="Enter workspace name" + maxLength={100} + status={nameError ? 'error' : undefined} + /> +
+ + {basicInfoChanges && canEdit && ( +
+ + + +
+ )} +
+ +
+ +
+ + {canEdit && ( +
+

Danger Zone

+ +
+
+
+
Delete this workspace
+
+ Once you delete a workspace, there is no going back. All data and + resources associated with this workspace will be permanently removed. +
+
+ +
+ +
+
+
+
+ )} + + +
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 98177dc..93c0e96 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -18,6 +18,32 @@ -moz-osx-font-smoothing: grayscale; } +div, +p, +span, +a, +button, +input, +textarea, +select, +option, +label, +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: + 'Jost', + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + sans-serif; +} + :root, html, body { diff --git a/frontend/src/pages/AuthPageComponent.tsx b/frontend/src/pages/AuthPageComponent.tsx index a93b4cf..c311249 100644 --- a/frontend/src/pages/AuthPageComponent.tsx +++ b/frontend/src/pages/AuthPageComponent.tsx @@ -1,41 +1,59 @@ +import { LoadingOutlined } from '@ant-design/icons'; import { Spin } from 'antd'; import { useEffect, useState } from 'react'; import { userApi } from '../entity/users'; -import { SignInComponent } from '../features/users'; -import { SignUpComponent } from '../features/users'; -import { AuthNavbarComponent } from '../features/users'; +import { + AdminPasswordComponent, + AuthNavbarComponent, + SignInComponent, + SignUpComponent, +} from '../features/users'; export function AuthPageComponent() { - const [isAnyUserExists, setIsAnyUserExists] = useState(false); - const [isLoading, setLoading] = useState(false); + const [isAdminHasPassword, setIsAdminHasPassword] = useState(false); + const [authMode, setAuthMode] = useState<'signIn' | 'signUp'>('signUp'); + const [isLoading, setLoading] = useState(true); - useEffect(() => { + const checkAdminPasswordStatus = () => { setLoading(true); userApi - .isAnyUserExists() - .then((isAnyUserExists) => { - setIsAnyUserExists(isAnyUserExists); - }) - .finally(() => { + .isAdminHasPassword() + .then((response) => { + setIsAdminHasPassword(response.hasPassword); setLoading(false); + }) + .catch((e) => { + alert('Failed to check admin password status: ' + (e as Error).message); }); + }; + + useEffect(() => { + checkAdminPasswordStatus(); }, []); return (
{isLoading ? (
- + } size="large" />
) : (
-
- {isAnyUserExists ? : } +
+ {isAdminHasPassword ? ( + authMode === 'signUp' ? ( + setAuthMode('signIn')} /> + ) : ( + setAuthMode('signUp')} /> + ) + ) : ( + + )}
diff --git a/frontend/src/pages/OAuthCallbackPage.tsx b/frontend/src/pages/OAuthCallbackPage.tsx new file mode 100644 index 0000000..395b027 --- /dev/null +++ b/frontend/src/pages/OAuthCallbackPage.tsx @@ -0,0 +1,76 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { Spin } from 'antd'; +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router'; + +import { getOAuthRedirectUri } from '../constants'; +import { userApi } from '../entity/users'; + +export function OAuthCallbackPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [error, setError] = useState(''); + + useEffect(() => { + const handleOAuthCallback = async () => { + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + if (!code) { + setError('Authorization code not found'); + return; + } + + if (!state) { + setError('OAuth state parameter missing'); + return; + } + + const redirectUri = getOAuthRedirectUri(); + + try { + if (state === 'github') { + await userApi.handleGitHubOAuth({ code, redirectUri }); + } else if (state === 'google') { + await userApi.handleGoogleOAuth({ code, redirectUri }); + } else { + setError('Invalid OAuth provider'); + return; + } + + navigate('/'); + } catch (e) { + setError((e as Error).message || 'OAuth authentication failed'); + } + }; + + handleOAuthCallback(); + }, [searchParams, navigate]); + + return ( +
+ {error ? ( +
+
+ Authentication Failed +
+
{error}
+
+ +
+
+ ) : ( +
+ } size="large" /> +
Completing authentication...
+
+ )} +
+ ); +} diff --git a/frontend/src/shared/lib/StringUtils.ts b/frontend/src/shared/lib/StringUtils.ts new file mode 100644 index 0000000..d2884ef --- /dev/null +++ b/frontend/src/shared/lib/StringUtils.ts @@ -0,0 +1,5 @@ +export class StringUtils { + static capitalizeFirstLetter(string: string): string { + return string.charAt(0).toUpperCase() + string.slice(1); + } +} diff --git a/frontend/src/shared/lib/index.ts b/frontend/src/shared/lib/index.ts index 95f6a16..709651d 100644 --- a/frontend/src/shared/lib/index.ts +++ b/frontend/src/shared/lib/index.ts @@ -1 +1,2 @@ export { FormValidator } from './FormValidator'; +export { StringUtils } from './StringUtils'; diff --git a/frontend/src/shared/time/index.ts b/frontend/src/shared/time/index.ts index 3897174..e796fe9 100644 --- a/frontend/src/shared/time/index.ts +++ b/frontend/src/shared/time/index.ts @@ -1 +1 @@ -export { getUserTimeFormat } from './getUserTimeFormat'; +export { getUserTimeFormat, getUserShortTimeFormat } from './getUserTimeFormat'; diff --git a/frontend/src/widgets/main/MainScreenComponent.tsx b/frontend/src/widgets/main/MainScreenComponent.tsx index 563fb14..843e353 100644 --- a/frontend/src/widgets/main/MainScreenComponent.tsx +++ b/frontend/src/widgets/main/MainScreenComponent.tsx @@ -1,37 +1,123 @@ -import { Tooltip } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; +import { App, Button, Spin, Tooltip } from 'antd'; import { useEffect, useState } from 'react'; import GitHubButton from 'react-github-btn'; -import { APP_VERSION, getApplicationServer } from '../../constants'; +import { APP_VERSION } from '../../constants'; import { type DiskUsage, diskApi } from '../../entity/disk'; +import { + type UserProfile, + UserRole, + type UsersSettings, + WorkspaceRole, + settingsApi, + userApi, +} from '../../entity/users'; +import { type WorkspaceResponse, workspaceApi } from '../../entity/workspaces'; import { DatabasesComponent } from '../../features/databases/ui/DatabasesComponent'; import { NotifiersComponent } from '../../features/notifiers/ui/NotifiersComponent'; -import { StoragesComponent } from '../../features/storages/StoragesComponent'; +import { SettingsComponent } from '../../features/settings'; +import { StoragesComponent } from '../../features/storages/ui/StoragesComponent'; +import { ProfileComponent } from '../../features/users'; +import { UsersComponent } from '../../features/users/ui/UsersComponent'; +import { + CreateWorkspaceDialogComponent, + WorkspaceSettingsComponent, +} from '../../features/workspaces'; import { useScreenHeight } from '../../shared/hooks'; +import { WorkspaceSelectionComponent } from './WorkspaceSelectionComponent'; export const MainScreenComponent = () => { + const { message } = App.useApp(); const screenHeight = useScreenHeight(); const contentHeight = screenHeight - 95; - const [selectedTab, setSelectedTab] = useState<'notifiers' | 'storages' | 'databases'>( - 'databases', - ); + const [selectedTab, setSelectedTab] = useState< + | 'notifiers' + | 'storages' + | 'databases' + | 'profile' + | 'postgresus-settings' + | 'users' + | 'settings' + >('databases'); const [diskUsage, setDiskUsage] = useState(undefined); + const [user, setUser] = useState(undefined); + const [globalSettings, setGlobalSettings] = useState(undefined); + + const [workspaces, setWorkspaces] = useState([]); + const [selectedWorkspace, setSelectedWorkspace] = useState( + undefined, + ); + + const [isLoading, setIsLoading] = useState(false); + const [showCreateWorkspaceDialog, setShowCreateWorkspaceDialog] = useState(false); + + const loadData = async () => { + setIsLoading(true); + + try { + const [diskUsage, user, workspaces, settings] = await Promise.all([ + diskApi.getDiskUsage(), + userApi.getCurrentUser(), + workspaceApi.getWorkspaces(), + settingsApi.getSettings(), + ]); + + setDiskUsage(diskUsage); + setUser(user); + setWorkspaces(workspaces.workspaces); + setGlobalSettings(settings); + } catch (e) { + message.error((e as Error).message); + } + + setIsLoading(false); + }; useEffect(() => { - diskApi - .getDiskUsage() - .then((diskUsage) => { - setDiskUsage(diskUsage); - }) - .catch((error) => { - alert(error.message); - }); + loadData(); }, []); + // Set selected workspace if none selected and workspaces available + useEffect(() => { + if (!selectedWorkspace && workspaces.length > 0) { + const previouslySelectedWorkspaceId = localStorage.getItem('selected_workspace_id'); + const previouslySelectedWorkspace = workspaces.find( + (workspace) => workspace.id === previouslySelectedWorkspaceId, + ); + const workspaceToSelect = previouslySelectedWorkspace || workspaces[0]; + setSelectedWorkspace(workspaceToSelect); + } + }, [workspaces, selectedWorkspace]); + + // Save selected workspace to localStorage + useEffect(() => { + if (selectedWorkspace) { + localStorage.setItem('selected_workspace_id', selectedWorkspace.id); + } + }, [selectedWorkspace]); + + const handleCreateWorkspace = () => { + setShowCreateWorkspaceDialog(true); + }; + + const handleWorkspaceCreated = async (newWorkspace: WorkspaceResponse) => { + try { + const workspacesResponse = await workspaceApi.getWorkspaces(); + setWorkspaces(workspacesResponse.workspaces); + setSelectedWorkspace(newWorkspace); + setSelectedTab('databases'); + } catch (e) { + message.error((e as Error).message); + } + }; + const isUsedMoreThan95Percent = diskUsage && diskUsage.usedSpaceBytes / diskUsage.totalSpaceBytes > 0.95; + const isCanManageDBs = selectedWorkspace?.userRole !== WorkspaceRole.VIEWER; + return (
{/* ===================== NAVBAR ===================== */} @@ -40,31 +126,22 @@ export const MainScreenComponent = () => { - -
- {/* ===================== END NAVBAR ===================== */} -
-
- {[ - { - text: 'Databases', - name: 'databases', - icon: '/icons/menu/database-gray.svg', - selectedIcon: '/icons/menu/database-white.svg', - onClick: () => setSelectedTab('databases'), - }, - { - text: 'Storages', - name: 'storages', - icon: '/icons/menu/storage-gray.svg', - selectedIcon: '/icons/menu/storage-white.svg', - onClick: () => setSelectedTab('storages'), - }, - { - text: 'Notifiers', - name: 'notifiers', - icon: '/icons/menu/notifier-gray.svg', - selectedIcon: '/icons/menu/notifier-white.svg', - onClick: () => setSelectedTab('notifiers'), - }, - ].map((tab) => ( -
-
-
-
- {tab.text} + {isLoading ? ( +
+ } size="large" /> +
+ ) : ( +
+
+ {[ + { + text: 'Databases', + name: 'databases', + icon: '/icons/menu/database-gray.svg', + selectedIcon: '/icons/menu/database-white.svg', + onClick: () => setSelectedTab('databases'), + isAdminOnly: false, + marginTop: '0px', + isVisible: true, + }, + { + text: 'Storages', + name: 'storages', + icon: '/icons/menu/storage-gray.svg', + selectedIcon: '/icons/menu/storage-white.svg', + onClick: () => setSelectedTab('storages'), + isAdminOnly: false, + marginTop: '0px', + isVisible: !!selectedWorkspace, + }, + { + text: 'Notifiers', + name: 'notifiers', + icon: '/icons/menu/notifier-gray.svg', + selectedIcon: '/icons/menu/notifier-white.svg', + onClick: () => setSelectedTab('notifiers'), + isAdminOnly: false, + marginTop: '0px', + isVisible: !!selectedWorkspace, + }, + { + text: 'Settings', + name: 'settings', + icon: '/icons/menu/workspace-settings-gray.svg', + selectedIcon: '/icons/menu/workspace-settings-white.svg', + onClick: () => setSelectedTab('settings'), + isAdminOnly: false, + marginTop: '0px', + isVisible: !!selectedWorkspace, + }, + { + text: 'Profile', + name: 'profile', + icon: '/icons/menu/profile-gray.svg', + selectedIcon: '/icons/menu/profile-white.svg', + onClick: () => setSelectedTab('profile'), + isAdminOnly: false, + marginTop: '25px', + isVisible: true, + }, + { + text: 'Postgresus settings', + name: 'postgresus-settings', + icon: '/icons/menu/global-settings-gray.svg', + selectedIcon: '/icons/menu/global-settings-white.svg', + onClick: () => setSelectedTab('postgresus-settings'), + isAdminOnly: true, + marginTop: '0px', + isVisible: true, + }, + { + text: 'Users', + name: 'users', + icon: '/icons/menu/user-card-gray.svg', + selectedIcon: '/icons/menu/user-card-white.svg', + onClick: () => setSelectedTab('users'), + isAdminOnly: true, + marginTop: '0px', + isVisible: true, + }, + ] + .filter((tab) => !tab.isAdminOnly || user?.role === UserRole.ADMIN) + .filter((tab) => tab.isVisible) + .map((tab) => ( +
+
+
+
+ {tab.text} +
+
-
+ ))} +
+ + {selectedTab === 'profile' && } + + {selectedTab === 'postgresus-settings' && ( + + )} + + {selectedTab === 'users' && } + + {workspaces.length === 0 && + (selectedTab === 'databases' || + selectedTab === 'storages' || + selectedTab === 'notifiers' || + selectedTab === 'settings') ? ( +
+
- ))} -
+ ) : ( + <> + {selectedTab === 'notifiers' && selectedWorkspace && ( + + )} + {selectedTab === 'storages' && selectedWorkspace && ( + + )} + {selectedTab === 'databases' && selectedWorkspace && ( + + )} + {selectedTab === 'settings' && selectedWorkspace && user && ( + + )} + + )} - {selectedTab === 'notifiers' && } - {selectedTab === 'storages' && } - {selectedTab === 'databases' && } - -
- v{APP_VERSION} +
+ v{APP_VERSION} +
-
+ )} + + {/* Create Workspace Dialog */} + {showCreateWorkspaceDialog && user && globalSettings && ( + setShowCreateWorkspaceDialog(false)} + onWorkspaceCreated={handleWorkspaceCreated} + workspacesCount={workspaces.length} + /> + )}
); }; diff --git a/frontend/src/widgets/main/WorkspaceSelectionComponent.tsx b/frontend/src/widgets/main/WorkspaceSelectionComponent.tsx new file mode 100644 index 0000000..9523ab6 --- /dev/null +++ b/frontend/src/widgets/main/WorkspaceSelectionComponent.tsx @@ -0,0 +1,135 @@ +import { Button, Input } from 'antd'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { type WorkspaceResponse } from '../../entity/workspaces'; + +interface Props { + workspaces: WorkspaceResponse[]; + selectedWorkspace?: WorkspaceResponse; + onCreateWorkspace: () => void; + onWorkspaceSelect: (workspace: WorkspaceResponse) => void; +} + +export const WorkspaceSelectionComponent = ({ + workspaces, + selectedWorkspace, + onCreateWorkspace, + onWorkspaceSelect, +}: Props) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const dropdownRef = useRef(null); + + const filteredWorkspaces = useMemo(() => { + if (!searchValue.trim()) return workspaces; + const searchTerm = searchValue.toLowerCase(); + return workspaces.filter((workspace) => workspace.name.toLowerCase().includes(searchTerm)); + }, [workspaces, searchValue]); + + const openWorkspace = (workspace: WorkspaceResponse) => { + setIsDropdownOpen(false); + setSearchValue(''); + onWorkspaceSelect?.(workspace); + }; + + // Handle click outside dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + setSearchValue(''); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + if (workspaces.length === 0) { + return ( + + ); + } + + return ( +
+
+ Selected workspace +
+ +
+ {/* Dropdown Trigger */} +
setIsDropdownOpen(!isDropdownOpen)} + > +
+
+ {selectedWorkspace?.name || 'Select a workspace'} +
+ arrow-down +
+
+ + {/* Dropdown Menu */} + {isDropdownOpen && ( +
+ {/* Search Input */} +
+ setSearchValue(e.target.value)} + className="border-0 shadow-none" + autoFocus + /> +
+ + {/* Workspace List */} +
+ {filteredWorkspaces.map((workspace) => ( +
openWorkspace(workspace)} + > + {workspace.name} +
+ ))} + + {filteredWorkspaces.length === 0 && searchValue && ( +
No workspaces found
+ )} +
+ + {/* Create New Workspace Button - Fixed at bottom */} +
+
{ + onCreateWorkspace(); + setIsDropdownOpen(false); + setSearchValue(''); + }} + > + + Create new workspace +
+
+
+ )} +
+
+ ); +};