mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f6cd7a5d | ||
|
|
d7b8e6d56a | ||
|
|
6016f23fb2 | ||
|
|
e7c4ee8f6f | ||
|
|
a75702a01b | ||
|
|
81a21eb907 | ||
|
|
33d6bf0147 | ||
|
|
6eb53bb07b | ||
|
|
6ac04270b9 | ||
|
|
b0510d7c21 | ||
|
|
dc5f271882 | ||
|
|
8f718771c9 | ||
|
|
d8eea05dca | ||
|
|
b2a94274d7 | ||
|
|
77c2712ebb | ||
|
|
a9dc29f82c | ||
|
|
c934a45dca | ||
|
|
d4acdf2826 | ||
|
|
49753c4fc0 | ||
|
|
c6aed6b36d | ||
|
|
3060b4266a | ||
|
|
ebeb597f17 | ||
|
|
4783784325 | ||
|
|
bd41433bdb | ||
|
|
a9073787d2 | ||
|
|
0890bf8f09 | ||
|
|
f8c11e8802 | ||
|
|
e798d82fc1 | ||
|
|
81a01585ee | ||
|
|
a8465c1a10 | ||
|
|
a9e5db70f6 | ||
|
|
7a47be6ca6 | ||
|
|
16be3db0c6 | ||
|
|
744e51d1e1 | ||
|
|
b3af75d430 | ||
|
|
6f7320abeb | ||
|
|
a1655d35a6 | ||
|
|
9b6e801184 | ||
|
|
105777ab6f | ||
|
|
3a1a88d5cf | ||
|
|
699ca16814 | ||
|
|
26f3cf233a | ||
|
|
3d8372e9f6 | ||
|
|
b46f11804d | ||
|
|
4676361688 | ||
|
|
de3679cadf | ||
|
|
8f03a30af2 | ||
|
|
356529c58a | ||
|
|
e7eed056f7 | ||
|
|
6084cdc954 | ||
|
|
c50bcc57b1 | ||
|
|
ea76300ed7 | ||
|
|
9b413e4076 | ||
|
|
f91cb260f2 | ||
|
|
8f37a8082f | ||
|
|
5cf7614772 | ||
|
|
ae27f74c2e | ||
|
|
9457516bb9 | ||
|
|
a36fc5bf8c | ||
|
|
03ada5806d | ||
|
|
a6675390e5 | ||
|
|
af2f978876 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
ansible/
|
||||
postgresus_data/
|
||||
postgresus-data/
|
||||
databasus-data/
|
||||
@@ -9,4 +10,5 @@ node_modules/
|
||||
/articles
|
||||
|
||||
.DS_Store
|
||||
/scripts
|
||||
/scripts
|
||||
.vscode/settings.json
|
||||
|
||||
@@ -18,6 +18,13 @@ repos:
|
||||
files: ^frontend/.*\.(ts|tsx|js|jsx)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: frontend-build
|
||||
name: Frontend Build
|
||||
entry: bash -c "cd frontend && npm run build"
|
||||
language: system
|
||||
files: ^frontend/.*\.(ts|tsx|js|jsx|json|css)$
|
||||
pass_filenames: false
|
||||
|
||||
# Backend checks
|
||||
- repo: local
|
||||
hooks:
|
||||
|
||||
305
AGENTS.md
305
AGENTS.md
@@ -1,35 +1,37 @@
|
||||
# Agent Rules and Guidelines
|
||||
|
||||
This document contains all coding standards, conventions and best practices recommended for the Databasus project.
|
||||
This document contains all coding standards, conventions and best practices recommended for the TgTaps project.
|
||||
This is NOT a strict set of rules, but a set of recommendations to help you write better code.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Engineering Philosophy](#engineering-philosophy)
|
||||
- [Backend Guidelines](#backend-guidelines)
|
||||
- [Code Style](#code-style)
|
||||
- [Engineering philosophy](#engineering-philosophy)
|
||||
- [Backend guidelines](#backend-guidelines)
|
||||
- [Code style](#code-style)
|
||||
- [Boolean naming](#boolean-naming)
|
||||
- [Add reasonable new lines between logical statements](#add-reasonable-new-lines-between-logical-statements)
|
||||
- [Comments](#comments)
|
||||
- [Controllers](#controllers)
|
||||
- [Dependency Injection (DI)](#dependency-injection-di)
|
||||
- [Dependency injection (DI)](#dependency-injection-di)
|
||||
- [Migrations](#migrations)
|
||||
- [Refactoring](#refactoring)
|
||||
- [Testing](#testing)
|
||||
- [Time Handling](#time-handling)
|
||||
- [CRUD Examples](#crud-examples)
|
||||
- [Frontend Guidelines](#frontend-guidelines)
|
||||
- [React Component Structure](#react-component-structure)
|
||||
- [Time handling](#time-handling)
|
||||
- [CRUD examples](#crud-examples)
|
||||
- [Frontend guidelines](#frontend-guidelines)
|
||||
- [React component structure](#react-component-structure)
|
||||
|
||||
---
|
||||
|
||||
## Engineering Philosophy
|
||||
## Engineering philosophy
|
||||
|
||||
**Think like a skeptical senior engineer and code reviewer. Don't just do what was asked—also think about what should have been asked.**
|
||||
|
||||
⚠️ **Balance vigilance with pragmatism:** Catch real issues, not theoretical ones. Don't let perfect be the enemy of good.
|
||||
|
||||
### Task Context Assessment:
|
||||
### Task context assessment:
|
||||
|
||||
**First, assess the task scope:**
|
||||
|
||||
@@ -38,7 +40,7 @@ This is NOT a strict set of rules, but a set of recommendations to help you writ
|
||||
- **Complex** (architecture, security, performance-critical): Full analysis required
|
||||
- **Unclear** (ambiguous requirements): Always clarify assumptions first
|
||||
|
||||
### For Non-Trivial Tasks:
|
||||
### For non-trivial tasks:
|
||||
|
||||
1. **Restate the objective and list assumptions** (explicit + implicit)
|
||||
- If any assumption is shaky, call it out clearly
|
||||
@@ -71,7 +73,7 @@ This is NOT a strict set of rules, but a set of recommendations to help you writ
|
||||
- Patch the answer accordingly
|
||||
- Verify edge cases are handled
|
||||
|
||||
### Application Guidelines:
|
||||
### Application guidelines:
|
||||
|
||||
**Scale your response to the task:**
|
||||
|
||||
@@ -84,9 +86,9 @@ This is NOT a strict set of rules, but a set of recommendations to help you writ
|
||||
|
||||
---
|
||||
|
||||
## Backend Guidelines
|
||||
## Backend guidelines
|
||||
|
||||
### Code Style
|
||||
### Code style
|
||||
|
||||
**Always place private methods to the bottom of file**
|
||||
|
||||
@@ -94,7 +96,7 @@ This rule applies to ALL Go files including tests, services, controllers, reposi
|
||||
|
||||
In Go, exported (public) functions/methods start with uppercase letters, while unexported (private) ones start with lowercase letters.
|
||||
|
||||
#### Structure Order:
|
||||
#### Structure order:
|
||||
|
||||
1. Type definitions and constants
|
||||
2. Public methods/functions (uppercase)
|
||||
@@ -227,7 +229,7 @@ func (c *ProjectController) extractProjectID(ctx *gin.Context) uuid.UUID {
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Points:
|
||||
#### Key points:
|
||||
|
||||
- **Exported/Public** = starts with uppercase letter (CreateUser, GetProject)
|
||||
- **Unexported/Private** = starts with lowercase letter (validateUser, handleError)
|
||||
@@ -237,13 +239,13 @@ func (c *ProjectController) extractProjectID(ctx *gin.Context) uuid.UUID {
|
||||
|
||||
---
|
||||
|
||||
### Boolean Naming
|
||||
### Boolean naming
|
||||
|
||||
**Always prefix boolean variables with verbs like `is`, `has`, `was`, `should`, `can`, etc.**
|
||||
|
||||
This makes the code more readable and clearly indicates that the variable represents a true/false state.
|
||||
|
||||
#### Good Examples:
|
||||
#### Good examples:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
@@ -265,7 +267,7 @@ wasCompleted := false
|
||||
hasPermission := checkPermissions()
|
||||
```
|
||||
|
||||
#### Bad Examples:
|
||||
#### Bad examples:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
@@ -286,7 +288,7 @@ completed := false // Should be: wasCompleted
|
||||
permission := true // Should be: hasPermission
|
||||
```
|
||||
|
||||
#### Common Boolean Prefixes:
|
||||
#### Common boolean prefixes:
|
||||
|
||||
- **is** - current state (IsActive, IsValid, IsEnabled)
|
||||
- **has** - possession or presence (HasAccess, HasPermission, HasError)
|
||||
@@ -297,6 +299,167 @@ permission := true // Should be: hasPermission
|
||||
|
||||
---
|
||||
|
||||
### Add reasonable new lines between logical statements
|
||||
|
||||
**Add blank lines between logical blocks to improve code readability.**
|
||||
|
||||
Separate different logical operations within a function with blank lines. This makes the code flow clearer and helps identify distinct steps in the logic.
|
||||
|
||||
#### Guidelines:
|
||||
|
||||
- Add blank line before final `return` statement
|
||||
- Add blank line after variable declarations before using them
|
||||
- Add blank line between error handling and subsequent logic
|
||||
- Add blank line between different logical operations
|
||||
|
||||
#### Bad example (without spacing):
|
||||
|
||||
```go
|
||||
func (t *Task) BeforeSave(tx *gorm.DB) error {
|
||||
if len(t.Messages) > 0 {
|
||||
messagesBytes, err := json.Marshal(t.Messages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.MessagesJSON = string(messagesBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Task) AfterFind(tx *gorm.DB) error {
|
||||
if t.MessagesJSON != "" {
|
||||
var messages []onewin_dto.TaskCompletionMessage
|
||||
if err := json.Unmarshal([]byte(t.MessagesJSON), &messages); err != nil {
|
||||
return err
|
||||
}
|
||||
t.Messages = messages
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
#### Good example (with proper spacing):
|
||||
|
||||
```go
|
||||
func (t *Task) BeforeSave(tx *gorm.DB) error {
|
||||
if len(t.Messages) > 0 {
|
||||
messagesBytes, err := json.Marshal(t.Messages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.MessagesJSON = string(messagesBytes)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Task) AfterFind(tx *gorm.DB) error {
|
||||
if t.MessagesJSON != "" {
|
||||
var messages []onewin_dto.TaskCompletionMessage
|
||||
if err := json.Unmarshal([]byte(t.MessagesJSON), &messages); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Messages = messages
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
#### More examples:
|
||||
|
||||
**Service method with multiple operations:**
|
||||
|
||||
```go
|
||||
func (s *UserService) CreateUser(request *CreateUserRequest) (*User, error) {
|
||||
// Validate input
|
||||
if err := s.validateUserRequest(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create user entity
|
||||
user := &User{
|
||||
ID: uuid.New(),
|
||||
Name: request.Name,
|
||||
Email: request.Email,
|
||||
}
|
||||
|
||||
// Save to database
|
||||
if err := s.repository.Create(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Send notification
|
||||
s.notificationService.SendWelcomeEmail(user.Email)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Repository method with query building:**
|
||||
|
||||
```go
|
||||
func (r *Repository) GetFiltered(filters *Filters) ([]*Entity, error) {
|
||||
query := storage.GetDb().Model(&Entity{})
|
||||
|
||||
if filters.Status != "" {
|
||||
query = query.Where("status = ?", filters.Status)
|
||||
}
|
||||
|
||||
if filters.CreatedAfter != nil {
|
||||
query = query.Where("created_at > ?", filters.CreatedAfter)
|
||||
}
|
||||
|
||||
var entities []*Entity
|
||||
if err := query.Find(&entities).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entities, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Repository method with error handling:**
|
||||
|
||||
Bad (without spacing):
|
||||
|
||||
```go
|
||||
func (r *Repository) FindById(id uuid.UUID) (*models.Task, error) {
|
||||
var task models.Task
|
||||
result := storage.GetDb().Where("id = ?", id).First(&task)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("task not found")
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
```
|
||||
|
||||
Good (with proper spacing):
|
||||
|
||||
```go
|
||||
func (r *Repository) FindById(id uuid.UUID) (*models.Task, error) {
|
||||
var task models.Task
|
||||
|
||||
result := storage.GetDb().Where("id = ?", id).First(&task)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("task not found")
|
||||
}
|
||||
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &task, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Comments
|
||||
|
||||
#### Guidelines
|
||||
@@ -305,13 +468,14 @@ permission := true // Should be: hasPermission
|
||||
2. **Functions and variables should have meaningful names** - Code should be self-documenting
|
||||
3. **Comments for unclear code only** - Only add comments when code logic isn't immediately clear
|
||||
|
||||
#### Key Principles:
|
||||
#### Key principles:
|
||||
|
||||
- **Code should tell a story** - Use descriptive variable and function names
|
||||
- **Comments explain WHY, not WHAT** - The code shows what happens, comments explain business logic or complex decisions
|
||||
- **Prefer refactoring over commenting** - If code needs explaining, consider making it clearer instead
|
||||
- **API documentation is required** - Swagger comments for all HTTP endpoints are mandatory
|
||||
- **Complex algorithms deserve comments** - Mathematical formulas, business rules, or non-obvious optimizations
|
||||
- **Do not write summary sections in .md files unless directly requested** - Avoid adding "Summary" or "Conclusion" sections at the end of documentation files unless the user explicitly asks for them
|
||||
|
||||
#### Example of useless comments:
|
||||
|
||||
@@ -343,7 +507,7 @@ func CreateValidLogItems(count int, uniqueID string) []logs_receiving.LogItemReq
|
||||
|
||||
### Controllers
|
||||
|
||||
#### Controller Guidelines:
|
||||
#### Controller guidelines:
|
||||
|
||||
1. **When we write controller:**
|
||||
- We combine all routes to single controller
|
||||
@@ -475,7 +639,7 @@ func (c *AuditLogController) GetUserAuditLogs(ctx *gin.Context) {
|
||||
|
||||
---
|
||||
|
||||
### Dependency Injection (DI)
|
||||
### Dependency injection (DI)
|
||||
|
||||
For DI files use **implicit fields declaration styles** (especially for controllers, services, repositories, use cases, etc., not simple data structures).
|
||||
|
||||
@@ -503,7 +667,7 @@ var orderController = &OrderController{
|
||||
|
||||
**This is needed to avoid forgetting to update DI style when we add new dependency.**
|
||||
|
||||
#### Force Such Usage
|
||||
#### Force such usage
|
||||
|
||||
Please force such usage if file look like this (see some services\controllers\repos definitions and getters):
|
||||
|
||||
@@ -549,13 +713,13 @@ func GetOrderRepository() *repositories.OrderRepository {
|
||||
}
|
||||
```
|
||||
|
||||
#### SetupDependencies() Pattern
|
||||
#### SetupDependencies() pattern
|
||||
|
||||
**All `SetupDependencies()` functions must use sync.Once to ensure idempotent execution.**
|
||||
|
||||
This pattern allows `SetupDependencies()` to be safely called multiple times (especially in tests) while ensuring the actual setup logic executes only once.
|
||||
|
||||
**Implementation Pattern:**
|
||||
**Implementation pattern:**
|
||||
|
||||
```go
|
||||
package feature
|
||||
@@ -573,22 +737,22 @@ var (
|
||||
|
||||
func SetupDependencies() {
|
||||
wasAlreadySetup := isSetup.Load()
|
||||
|
||||
|
||||
setupOnce.Do(func() {
|
||||
// Initialize dependencies here
|
||||
someService.SetDependency(otherService)
|
||||
anotherService.AddListener(listener)
|
||||
|
||||
|
||||
isSetup.Store(true)
|
||||
})
|
||||
|
||||
|
||||
if wasAlreadySetup {
|
||||
logger.GetLogger().Warn("SetupDependencies called multiple times, ignoring subsequent call")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Pattern:**
|
||||
**Why this pattern:**
|
||||
|
||||
- **Tests can call multiple times**: Test setup often calls `SetupDependencies()` multiple times without issues
|
||||
- **Thread-safe**: Works correctly with concurrent calls (nanoseconds or seconds apart)
|
||||
@@ -604,13 +768,13 @@ func SetupDependencies() {
|
||||
|
||||
---
|
||||
|
||||
### Background Services
|
||||
### Background services
|
||||
|
||||
**All background service `Run()` methods must panic if called multiple times to prevent corrupted states.**
|
||||
|
||||
Background services run infinite loops and must never be started twice on the same instance. Multiple calls indicate a serious bug that would cause duplicate goroutines, resource leaks, and data corruption.
|
||||
|
||||
**Implementation Pattern:**
|
||||
**Implementation pattern:**
|
||||
|
||||
```go
|
||||
package feature
|
||||
@@ -630,14 +794,14 @@ type BackgroundService struct {
|
||||
|
||||
func (s *BackgroundService) Run(ctx context.Context) {
|
||||
wasAlreadyRun := s.hasRun.Load()
|
||||
|
||||
|
||||
s.runOnce.Do(func() {
|
||||
s.hasRun.Store(true)
|
||||
|
||||
|
||||
// Existing infinite loop logic
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -647,21 +811,21 @@ func (s *BackgroundService) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if wasAlreadyRun {
|
||||
panic(fmt.Sprintf("%T.Run() called multiple times", s))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why Panic Instead of Warning:**
|
||||
**Why panic instead of warning:**
|
||||
|
||||
- **Prevents corruption**: Multiple `Run()` calls would create duplicate goroutines consuming resources
|
||||
- **Fails fast**: Catches critical bugs immediately in tests and production
|
||||
- **Clear indication**: Panic clearly indicates a serious programming error
|
||||
- **Applies everywhere**: Same protection in tests and production
|
||||
|
||||
**When This Applies:**
|
||||
**When this applies:**
|
||||
|
||||
- All background services with infinite loops
|
||||
- Registry services (BackupNodesRegistry, RestoreNodesRegistry)
|
||||
@@ -727,14 +891,14 @@ You can shortify, make more readable, improve code quality, etc. Common logic ca
|
||||
|
||||
**After writing tests, always launch them and verify that they pass.**
|
||||
|
||||
#### Test Naming Format
|
||||
#### Test naming format
|
||||
|
||||
Use these naming patterns:
|
||||
|
||||
- `Test_WhatWeDo_WhatWeExpect`
|
||||
- `Test_WhatWeDo_WhichConditions_WhatWeExpect`
|
||||
|
||||
#### Examples from Real Codebase:
|
||||
#### Examples from real codebase:
|
||||
|
||||
- `Test_CreateApiKey_WhenUserIsProjectOwner_ApiKeyCreated`
|
||||
- `Test_UpdateProject_WhenUserIsProjectAdmin_ProjectUpdated`
|
||||
@@ -742,22 +906,22 @@ Use these naming patterns:
|
||||
- `Test_GetProjectAuditLogs_WithDifferentUserRoles_EnforcesPermissionsCorrectly`
|
||||
- `Test_ProjectLifecycleE2E_CompletesSuccessfully`
|
||||
|
||||
#### Testing Philosophy
|
||||
#### Testing philosophy
|
||||
|
||||
**Prefer Controllers Over Unit Tests:**
|
||||
**Prefer controllers over unit tests:**
|
||||
|
||||
- Test through HTTP endpoints via controllers whenever possible
|
||||
- Avoid testing repositories, services in isolation - test via API instead
|
||||
- Only use unit tests for complex model logic when no API exists
|
||||
- Name test files `controller_test.go` or `service_test.go`, not `integration_test.go`
|
||||
|
||||
**Extract Common Logic to Testing Utilities:**
|
||||
**Extract common logic to testing utilities:**
|
||||
|
||||
- Create `testing.go` or `testing/testing.go` files for shared test utilities
|
||||
- Extract router creation, user setup, models creation helpers (in API, not just structs creation)
|
||||
- Reuse common patterns across different test files
|
||||
|
||||
**Refactor Existing Tests:**
|
||||
**Refactor existing tests:**
|
||||
|
||||
- When working with existing tests, always look for opportunities to refactor and improve
|
||||
- Extract repetitive setup code to common utilities
|
||||
@@ -766,7 +930,44 @@ Use these naming patterns:
|
||||
- Consolidate similar test patterns across different test files
|
||||
- Make tests more readable and maintainable for other developers
|
||||
|
||||
#### Testing Utilities Structure
|
||||
**Clean up test data:**
|
||||
|
||||
- If the feature supports cleanup operations (DELETE endpoints, cleanup methods), use them in tests
|
||||
- Clean up resources after test execution to avoid test data pollution
|
||||
- Use `defer` statements or explicit cleanup calls at the end of tests
|
||||
- Prioritize using API methods for cleanup (not direct database deletion)
|
||||
- Examples:
|
||||
- CRUD features: delete created records via DELETE endpoint
|
||||
- File uploads: remove uploaded files
|
||||
- Background jobs: stop schedulers or cancel running tasks
|
||||
- Skip cleanup only when:
|
||||
- Tests run in isolated transactions that auto-rollback
|
||||
- Cleanup endpoint doesn't exist yet
|
||||
- Test explicitly validates failure scenarios where cleanup isn't possible
|
||||
|
||||
**Example:**
|
||||
|
||||
```go
|
||||
func Test_BackupLifecycle_CreateAndDelete(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test", owner)
|
||||
|
||||
// Create backup config
|
||||
config := createBackupConfig(t, router, workspace.ID, owner.Token)
|
||||
|
||||
// Cleanup at end of test
|
||||
defer deleteBackupConfig(t, router, workspace.ID, config.ID, owner.Token)
|
||||
|
||||
// Test operations...
|
||||
triggerBackup(t, router, workspace.ID, config.ID, owner.Token)
|
||||
|
||||
// Verify backup was created
|
||||
backups := getBackups(t, router, workspace.ID, owner.Token)
|
||||
assert.NotEmpty(t, backups)
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing utilities structure
|
||||
|
||||
**Create `testing.go` or `testing/testing.go` files with common utilities:**
|
||||
|
||||
@@ -802,7 +1003,7 @@ func AddMemberToProject(project *projects_models.Project, member *users_dto.Sign
|
||||
}
|
||||
```
|
||||
|
||||
#### Controller Test Examples
|
||||
#### Controller test examples
|
||||
|
||||
**Permission-based testing:**
|
||||
|
||||
@@ -869,7 +1070,7 @@ func Test_ProjectLifecycleE2E_CompletesSuccessfully(t *testing.T) {
|
||||
|
||||
---
|
||||
|
||||
### Time Handling
|
||||
### Time handling
|
||||
|
||||
**Always use `time.Now().UTC()` instead of `time.Now()`**
|
||||
|
||||
@@ -877,7 +1078,7 @@ This ensures consistent timezone handling across the application.
|
||||
|
||||
---
|
||||
|
||||
### CRUD Examples
|
||||
### CRUD examples
|
||||
|
||||
This is an example of complete CRUD implementation structure:
|
||||
|
||||
@@ -1541,9 +1742,9 @@ func createTimedLog(db *gorm.DB, userID *uuid.UUID, message string, createdAt ti
|
||||
|
||||
---
|
||||
|
||||
## Frontend Guidelines
|
||||
## Frontend guidelines
|
||||
|
||||
### React Component Structure
|
||||
### React component structure
|
||||
|
||||
Write React components with the following structure:
|
||||
|
||||
@@ -1577,7 +1778,7 @@ export const ReactComponent = ({ someValue }: Props): JSX.Element => {
|
||||
}
|
||||
```
|
||||
|
||||
#### Structure Order:
|
||||
#### Structure order:
|
||||
|
||||
1. **Props interface** - Define component props
|
||||
2. **Helper functions** (outside component) - Pure utility functions
|
||||
|
||||
60
Dockerfile
60
Dockerfile
@@ -251,6 +251,36 @@ fi
|
||||
# PostgreSQL 17 binary paths
|
||||
PG_BIN="/usr/lib/postgresql/17/bin"
|
||||
|
||||
# Generate runtime configuration for frontend
|
||||
echo "Generating runtime configuration..."
|
||||
|
||||
# Detect if email is configured (both SMTP_HOST and DATABASUS_URL must be set)
|
||||
if [ -n "\${SMTP_HOST:-}" ] && [ -n "\${DATABASUS_URL:-}" ]; then
|
||||
IS_EMAIL_CONFIGURED="true"
|
||||
else
|
||||
IS_EMAIL_CONFIGURED="false"
|
||||
fi
|
||||
|
||||
cat > /app/ui/build/runtime-config.js <<JSEOF
|
||||
// Runtime configuration injected at container startup
|
||||
// This file is generated dynamically and should not be edited manually
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
IS_CLOUD: '\${IS_CLOUD:-false}',
|
||||
GITHUB_CLIENT_ID: '\${GITHUB_CLIENT_ID:-}',
|
||||
GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}',
|
||||
IS_EMAIL_CONFIGURED: '\$IS_EMAIL_CONFIGURED'
|
||||
};
|
||||
JSEOF
|
||||
|
||||
# Inject analytics script if provided (only if not already injected)
|
||||
if [ -n "\${ANALYTICS_SCRIPT:-}" ]; then
|
||||
if ! grep -q "rybbit.databasus.com" /app/ui/build/index.html 2>/dev/null; then
|
||||
echo "Injecting analytics script..."
|
||||
sed -i "s#</head># \${ANALYTICS_SCRIPT}\\
|
||||
</head>#" /app/ui/build/index.html
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure proper ownership of data directory
|
||||
echo "Setting up data directory permissions..."
|
||||
mkdir -p /databasus-data/pgdata
|
||||
@@ -372,9 +402,37 @@ SQL
|
||||
|
||||
# Start the main application
|
||||
echo "Starting Databasus application..."
|
||||
|
||||
# Check and warn about external database/Valkey usage
|
||||
if [ -n "\${DANGEROUS_EXTERNAL_DATABASE_DSN:-}" ]; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "WARNING: Using external database"
|
||||
echo "=========================================="
|
||||
echo "DANGEROUS_EXTERNAL_DATABASE_DSN is set."
|
||||
echo "Application will connect to external PostgreSQL instead of internal instance."
|
||||
echo "Internal PostgreSQL is still running in the background."
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "\${DANGEROUS_VALKEY_HOST:-}" ]; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "WARNING: Using external Valkey"
|
||||
echo "=========================================="
|
||||
echo "DANGEROUS_VALKEY_HOST is set."
|
||||
echo "Application will connect to external Valkey instead of internal instance."
|
||||
echo "Internal Valkey is still running in the background."
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
exec ./main
|
||||
EOF
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/databasus/databasus"
|
||||
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
EXPOSE 4005
|
||||
@@ -383,4 +441,4 @@ EXPOSE 4005
|
||||
VOLUME ["/databasus-data"]
|
||||
|
||||
ENTRYPOINT ["/app/start.sh"]
|
||||
CMD []
|
||||
CMD []
|
||||
|
||||
@@ -6,6 +6,8 @@ DEV_DB_PASSWORD=Q1234567
|
||||
ENV_MODE=development
|
||||
# logging
|
||||
SHOW_DB_INSTALLATION_VERIFICATION_LOGS=true
|
||||
VICTORIA_LOGS_URL=http://localhost:9428
|
||||
VICTORIA_LOGS_PASSWORD=devpassword
|
||||
# tests
|
||||
TEST_LOCALHOST=localhost
|
||||
IS_SKIP_EXTERNAL_RESOURCES_TESTS=false
|
||||
|
||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -18,4 +18,5 @@ pgdata-for-restore/
|
||||
temp/
|
||||
cmd.exe
|
||||
temp/
|
||||
valkey-data/
|
||||
valkey-data/
|
||||
victoria-logs-data/
|
||||
@@ -185,6 +185,9 @@ func startServerWithGracefulShutdown(log *slog.Logger, app *gin.Engine) {
|
||||
<-quit
|
||||
log.Info("Shutdown signal received")
|
||||
|
||||
// Gracefully shutdown VictoriaLogs writer
|
||||
logger.ShutdownVictoriaLogs(5 * time.Second)
|
||||
|
||||
// The context is used to inform the server it has 10 seconds to finish
|
||||
// the request it is currently handling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
@@ -34,6 +34,20 @@ services:
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
# VictoriaLogs for external logging
|
||||
victoria-logs:
|
||||
image: victoriametrics/victoria-logs:latest
|
||||
container_name: victoria-logs
|
||||
ports:
|
||||
- "9428:9428"
|
||||
command:
|
||||
- -storageDataPath=/victoria-logs-data
|
||||
- -retentionPeriod=7d
|
||||
- -httpAuth.password=devpassword
|
||||
volumes:
|
||||
- ./victoria-logs-data:/victoria-logs-data
|
||||
restart: unless-stopped
|
||||
|
||||
# Test MinIO container
|
||||
test-minio:
|
||||
image: minio/minio:latest
|
||||
|
||||
@@ -22,13 +22,22 @@ const (
|
||||
|
||||
type EnvVariables struct {
|
||||
IsTesting bool
|
||||
DatabaseDsn string `env:"DATABASE_DSN" required:"true"`
|
||||
EnvMode env_utils.EnvMode `env:"ENV_MODE" required:"true"`
|
||||
PostgresesInstallDir string `env:"POSTGRES_INSTALL_DIR"`
|
||||
MysqlInstallDir string `env:"MYSQL_INSTALL_DIR"`
|
||||
MariadbInstallDir string `env:"MARIADB_INSTALL_DIR"`
|
||||
MongodbInstallDir string `env:"MONGODB_INSTALL_DIR"`
|
||||
|
||||
// Internal database
|
||||
DatabaseDsn string `env:"DATABASE_DSN" required:"true"`
|
||||
// Internal Valkey
|
||||
ValkeyHost string `env:"VALKEY_HOST" required:"true"`
|
||||
ValkeyPort string `env:"VALKEY_PORT" required:"true"`
|
||||
ValkeyUsername string `env:"VALKEY_USERNAME" required:"true"`
|
||||
ValkeyPassword string `env:"VALKEY_PASSWORD" required:"true"`
|
||||
ValkeyIsSsl bool `env:"VALKEY_IS_SSL" required:"true"`
|
||||
|
||||
IsCloud bool `env:"IS_CLOUD"`
|
||||
TestLocalhost string `env:"TEST_LOCALHOST"`
|
||||
|
||||
ShowDbInstallationVerificationLogs bool `env:"SHOW_DB_INSTALLATION_VERIFICATION_LOGS"`
|
||||
@@ -89,13 +98,6 @@ type EnvVariables struct {
|
||||
TestMongodb70Port string `env:"TEST_MONGODB_70_PORT"`
|
||||
TestMongodb82Port string `env:"TEST_MONGODB_82_PORT"`
|
||||
|
||||
// Valkey
|
||||
ValkeyHost string `env:"VALKEY_HOST" required:"true"`
|
||||
ValkeyPort string `env:"VALKEY_PORT" required:"true"`
|
||||
ValkeyUsername string `env:"VALKEY_USERNAME"`
|
||||
ValkeyPassword string `env:"VALKEY_PASSWORD"`
|
||||
ValkeyIsSsl bool `env:"VALKEY_IS_SSL" required:"true"`
|
||||
|
||||
// oauth
|
||||
GitHubClientID string `env:"GITHUB_CLIENT_ID"`
|
||||
GitHubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
|
||||
@@ -112,6 +114,15 @@ type EnvVariables struct {
|
||||
TestSupabaseUsername string `env:"TEST_SUPABASE_USERNAME"`
|
||||
TestSupabasePassword string `env:"TEST_SUPABASE_PASSWORD"`
|
||||
TestSupabaseDatabase string `env:"TEST_SUPABASE_DATABASE"`
|
||||
|
||||
// SMTP configuration (optional)
|
||||
SMTPHost string `env:"SMTP_HOST"`
|
||||
SMTPPort int `env:"SMTP_PORT"`
|
||||
SMTPUser string `env:"SMTP_USER"`
|
||||
SMTPPassword string `env:"SMTP_PASSWORD"`
|
||||
|
||||
// Application URL (optional) - used for email links
|
||||
DatabasusURL string `env:"DATABASUS_URL"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -182,6 +193,11 @@ func loadEnvVariables() {
|
||||
env.IsSkipExternalResourcesTests = false
|
||||
}
|
||||
|
||||
// Set default value for IsCloud if not defined
|
||||
if os.Getenv("IS_CLOUD") == "" {
|
||||
env.IsCloud = false
|
||||
}
|
||||
|
||||
for _, arg := range os.Args {
|
||||
if strings.Contains(arg, "test") {
|
||||
env.IsTesting = true
|
||||
@@ -189,6 +205,14 @@ func loadEnvVariables() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for external database override
|
||||
if externalDsn := os.Getenv("DANGEROUS_EXTERNAL_DATABASE_DSN"); externalDsn != "" {
|
||||
log.Warn(
|
||||
"Using DANGEROUS_EXTERNAL_DATABASE_DSN - connecting to external database instead of internal PostgreSQL",
|
||||
)
|
||||
env.DatabaseDsn = externalDsn
|
||||
}
|
||||
|
||||
if env.DatabaseDsn == "" {
|
||||
log.Error("DATABASE_DSN is empty")
|
||||
os.Exit(1)
|
||||
@@ -259,6 +283,27 @@ func loadEnvVariables() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check for external Valkey override
|
||||
if externalValkeyHost := os.Getenv("DANGEROUS_VALKEY_HOST"); externalValkeyHost != "" {
|
||||
log.Warn(
|
||||
"Using DANGEROUS_VALKEY_* variables - connecting to external Valkey instead of internal instance",
|
||||
)
|
||||
env.ValkeyHost = externalValkeyHost
|
||||
|
||||
if externalValkeyPort := os.Getenv("DANGEROUS_VALKEY_PORT"); externalValkeyPort != "" {
|
||||
env.ValkeyPort = externalValkeyPort
|
||||
}
|
||||
if externalValkeyUsername := os.Getenv("DANGEROUS_VALKEY_USERNAME"); externalValkeyUsername != "" {
|
||||
env.ValkeyUsername = externalValkeyUsername
|
||||
}
|
||||
if externalValkeyPassword := os.Getenv("DANGEROUS_VALKEY_PASSWORD"); externalValkeyPassword != "" {
|
||||
env.ValkeyPassword = externalValkeyPassword
|
||||
}
|
||||
if externalValkeyIsSsl := os.Getenv("DANGEROUS_VALKEY_IS_SSL"); externalValkeyIsSsl != "" {
|
||||
env.ValkeyIsSsl = externalValkeyIsSsl == "true"
|
||||
}
|
||||
}
|
||||
|
||||
// Store the data and temp folders one level below the root
|
||||
// (projectRoot/databasus-data -> /databasus-data)
|
||||
env.DataFolder = filepath.Join(filepath.Dir(backendRoot), "databasus-data", "backups")
|
||||
|
||||
@@ -28,6 +28,19 @@ func Test_CleanOldBackups_DeletesBackupsOlderThanStorePeriod(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -96,6 +109,19 @@ func Test_CleanOldBackups_SkipsDatabaseWithForeverStorePeriod(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -142,6 +168,19 @@ func Test_CleanExceededBackups_WhenUnderLimit_NoBackupsDeleted(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -190,6 +229,19 @@ func Test_CleanExceededBackups_WhenOverLimit_DeletesOldestBackups(t *testing.T)
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -252,6 +304,19 @@ func Test_CleanExceededBackups_SkipsInProgressBackups(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -328,6 +393,19 @@ func Test_CleanExceededBackups_WithZeroLimit_SkipsDatabase(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create backup interval
|
||||
interval := createTestInterval()
|
||||
|
||||
@@ -376,6 +454,19 @@ func Test_GetTotalSizeByDatabase_CalculatesCorrectly(t *testing.T) {
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, storage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Create completed backups
|
||||
completedBackup1 := &backups_core.Backup{
|
||||
ID: uuid.New(),
|
||||
@@ -454,6 +545,19 @@ func Test_DeleteBackup_WhenStorageDeleteFails_BackupStillRemovedFromDatabase(t *
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
database := databases.CreateTestDatabase(workspace.ID, testStorage, notifier)
|
||||
|
||||
defer func() {
|
||||
backups, _ := backupRepository.FindByDatabaseID(database.ID)
|
||||
for _, backup := range backups {
|
||||
backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
storages.RemoveTestStorage(testStorage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
backup := &backups_core.Backup{
|
||||
ID: uuid.New(),
|
||||
DatabaseID: database.ID,
|
||||
|
||||
@@ -103,47 +103,14 @@ func (s *BackupsScheduler) IsSchedulerRunning() bool {
|
||||
return s.lastBackupTime.After(time.Now().UTC().Add(-schedulerHealthcheckThreshold))
|
||||
}
|
||||
|
||||
func (s *BackupsScheduler) failBackupsInProgress() error {
|
||||
backupsInProgress, err := s.backupRepository.FindByStatus(backups_core.BackupStatusInProgress)
|
||||
func (s *BackupsScheduler) IsBackupNodesAvailable() bool {
|
||||
nodes, err := s.backupNodesRegistry.GetAvailableNodes()
|
||||
if err != nil {
|
||||
return err
|
||||
s.logger.Error("Failed to get available nodes for health check", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
for _, backup := range backupsInProgress {
|
||||
if err := s.taskCancelManager.CancelTask(backup.ID); err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to cancel backup via task cancel manager",
|
||||
"backupId",
|
||||
backup.ID,
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(backup.DatabaseID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get backup config by database ID", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
failMessage := "Backup failed due to application restart"
|
||||
backup.FailMessage = &failMessage
|
||||
backup.Status = backups_core.BackupStatusFailed
|
||||
backup.BackupSizeMb = 0
|
||||
|
||||
s.backuperNode.SendBackupNotification(
|
||||
backupConfig,
|
||||
backup,
|
||||
backups_config.NotificationBackupFailed,
|
||||
&failMessage,
|
||||
)
|
||||
|
||||
if err := s.backupRepository.Save(backup); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return len(nodes) > 0
|
||||
}
|
||||
|
||||
func (s *BackupsScheduler) StartBackup(databaseID uuid.UUID, isCallNotifier bool) {
|
||||
@@ -197,6 +164,7 @@ func (s *BackupsScheduler) StartBackup(databaseID uuid.UUID, isCallNotifier bool
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("make backup")
|
||||
backup := &backups_core.Backup{
|
||||
DatabaseID: backupConfig.DatabaseID,
|
||||
StorageID: *backupConfig.StorageID,
|
||||
@@ -369,6 +337,49 @@ func (s *BackupsScheduler) runPendingBackups() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BackupsScheduler) failBackupsInProgress() error {
|
||||
backupsInProgress, err := s.backupRepository.FindByStatus(backups_core.BackupStatusInProgress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, backup := range backupsInProgress {
|
||||
if err := s.taskCancelManager.CancelTask(backup.ID); err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to cancel backup via task cancel manager",
|
||||
"backupId",
|
||||
backup.ID,
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(backup.DatabaseID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get backup config by database ID", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
failMessage := "Backup failed due to application restart"
|
||||
backup.FailMessage = &failMessage
|
||||
backup.Status = backups_core.BackupStatusFailed
|
||||
backup.BackupSizeMb = 0
|
||||
|
||||
s.backuperNode.SendBackupNotification(
|
||||
backupConfig,
|
||||
backup,
|
||||
backups_config.NotificationBackupFailed,
|
||||
&failMessage,
|
||||
)
|
||||
|
||||
if err := s.backupRepository.Save(backup); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BackupsScheduler) calculateLeastBusyNode() (*uuid.UUID, error) {
|
||||
nodes, err := s.backupNodesRegistry.GetAvailableNodes()
|
||||
if err != nil {
|
||||
|
||||
@@ -80,7 +80,7 @@ func Test_GetBackups_PermissionsEnforced(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, _ := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, _, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -122,6 +122,12 @@ func Test_GetBackups_PermissionsEnforced(t *testing.T) {
|
||||
} else {
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -218,6 +224,10 @@ func Test_CreateBackup_PermissionsEnforced(t *testing.T) {
|
||||
} else {
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -261,6 +271,10 @@ func Test_CreateBackup_AuditLogWritten(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Audit log for backup creation not found")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DeleteBackup_PermissionsEnforced(t *testing.T) {
|
||||
@@ -314,7 +328,7 @@ func Test_DeleteBackup_PermissionsEnforced(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -358,6 +372,12 @@ func Test_DeleteBackup_PermissionsEnforced(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(response.Backups))
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -367,7 +387,7 @@ func Test_DeleteBackup_AuditLogWritten(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
test_utils.MakeDeleteRequest(
|
||||
t,
|
||||
@@ -398,6 +418,12 @@ func Test_DeleteBackup_AuditLogWritten(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Audit log for backup deletion not found")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_GenerateDownloadToken_PermissionsEnforced(t *testing.T) {
|
||||
@@ -444,7 +470,7 @@ func Test_GenerateDownloadToken_PermissionsEnforced(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -488,6 +514,12 @@ func Test_GenerateDownloadToken_PermissionsEnforced(t *testing.T) {
|
||||
} else {
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -497,7 +529,7 @@ func Test_DownloadBackup_WithValidToken_Success(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Generate download token
|
||||
var tokenResponse backups_download.GenerateDownloadTokenResponse
|
||||
@@ -524,6 +556,12 @@ func Test_DownloadBackup_WithValidToken_Success(t *testing.T) {
|
||||
contentDisposition := testResp.Headers.Get("Content-Disposition")
|
||||
assert.Contains(t, contentDisposition, "attachment")
|
||||
assert.Contains(t, contentDisposition, tokenResponse.Filename)
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_WithoutToken_Unauthorized(t *testing.T) {
|
||||
@@ -531,7 +569,7 @@ func Test_DownloadBackup_WithoutToken_Unauthorized(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Try to download without token
|
||||
testResp := test_utils.MakeGetRequest(
|
||||
@@ -543,6 +581,12 @@ func Test_DownloadBackup_WithoutToken_Unauthorized(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, string(testResp.Body), "download token is required")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_WithInvalidToken_Unauthorized(t *testing.T) {
|
||||
@@ -550,7 +594,7 @@ func Test_DownloadBackup_WithInvalidToken_Unauthorized(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Try to download with invalid token
|
||||
testResp := test_utils.MakeGetRequest(
|
||||
@@ -562,6 +606,12 @@ func Test_DownloadBackup_WithInvalidToken_Unauthorized(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, string(testResp.Body), "invalid or expired download token")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_WithExpiredToken_Unauthorized(t *testing.T) {
|
||||
@@ -569,7 +619,7 @@ func Test_DownloadBackup_WithExpiredToken_Unauthorized(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Get user for token generation
|
||||
userService := users_services.GetUserService()
|
||||
@@ -611,6 +661,12 @@ func Test_DownloadBackup_WithExpiredToken_Unauthorized(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.False(t, found, "Audit log should NOT be created for failed download with expired token")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_TokenUsedOnce_CannotReuseToken(t *testing.T) {
|
||||
@@ -618,7 +674,7 @@ func Test_DownloadBackup_TokenUsedOnce_CannotReuseToken(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Generate download token
|
||||
var tokenResponse backups_download.GenerateDownloadTokenResponse
|
||||
@@ -651,6 +707,12 @@ func Test_DownloadBackup_TokenUsedOnce_CannotReuseToken(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, string(testResp.Body), "invalid or expired download token")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_WithDifferentBackupToken_Unauthorized(t *testing.T) {
|
||||
@@ -705,6 +767,13 @@ func Test_DownloadBackup_WithDifferentBackupToken_Unauthorized(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Contains(t, string(testResp.Body), "invalid or expired download token")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database1)
|
||||
databases.RemoveTestDatabase(database2)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_AuditLogWritten(t *testing.T) {
|
||||
@@ -712,7 +781,7 @@ func Test_DownloadBackup_AuditLogWritten(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
// Generate download token
|
||||
var tokenResponse backups_download.GenerateDownloadTokenResponse
|
||||
@@ -756,6 +825,12 @@ func Test_DownloadBackup_AuditLogWritten(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Audit log for backup download not found")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DownloadBackup_ProperFilenameForPostgreSQL(t *testing.T) {
|
||||
@@ -856,6 +931,12 @@ func Test_DownloadBackup_ProperFilenameForPostgreSQL(t *testing.T) {
|
||||
contentDisposition,
|
||||
"Filename should contain timestamp",
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -948,6 +1029,12 @@ func Test_CancelBackup_InProgressBackup_SuccessfullyCancelled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.True(t, foundCancelLog, "Cancel audit log should be created")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_ConcurrentDownloadPrevention(t *testing.T) {
|
||||
@@ -955,7 +1042,7 @@ func Test_ConcurrentDownloadPrevention(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
var token1Response backups_download.GenerateDownloadTokenResponse
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
@@ -1003,6 +1090,12 @@ func Test_ConcurrentDownloadPrevention(t *testing.T) {
|
||||
if !service.IsDownloadInProgress(owner.UserID) {
|
||||
t.Log("Warning: First download completed before we could test concurrency")
|
||||
<-downloadComplete
|
||||
|
||||
// Cleanup before early return
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1049,6 +1142,12 @@ func Test_ConcurrentDownloadPrevention(t *testing.T) {
|
||||
t.Log(
|
||||
"Successfully prevented concurrent downloads and allowed subsequent downloads after completion",
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_GenerateDownloadToken_BlockedWhenDownloadInProgress(t *testing.T) {
|
||||
@@ -1056,7 +1155,7 @@ func Test_GenerateDownloadToken_BlockedWhenDownloadInProgress(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
var token1Response backups_download.GenerateDownloadTokenResponse
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
@@ -1092,6 +1191,12 @@ func Test_GenerateDownloadToken_BlockedWhenDownloadInProgress(t *testing.T) {
|
||||
if !service.IsDownloadInProgress(owner.UserID) {
|
||||
t.Log("Warning: First download completed before we could test token generation blocking")
|
||||
<-downloadComplete
|
||||
|
||||
// Cleanup before early return
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1131,6 +1236,12 @@ func Test_GenerateDownloadToken_BlockedWhenDownloadInProgress(t *testing.T) {
|
||||
t.Log(
|
||||
"Successfully blocked token generation during download and allowed generation after completion",
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func createTestRouter() *gin.Engine {
|
||||
@@ -1222,7 +1333,7 @@ func createTestDatabaseWithBackups(
|
||||
workspace *workspaces_models.Workspace,
|
||||
owner *users_dto.SignInResponseDTO,
|
||||
router *gin.Engine,
|
||||
) (*databases.Database, *backups_core.Backup) {
|
||||
) (*databases.Database, *backups_core.Backup, *storages.Storage) {
|
||||
database := createTestDatabase("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
@@ -1242,7 +1353,7 @@ func createTestDatabaseWithBackups(
|
||||
|
||||
backup := createTestBackup(database, owner)
|
||||
|
||||
return database, backup
|
||||
return database, backup, storage
|
||||
}
|
||||
|
||||
func createTestBackup(
|
||||
@@ -1255,11 +1366,24 @@ func createTestBackup(
|
||||
panic(err)
|
||||
}
|
||||
|
||||
storages, err := storages.GetStorageService().GetStorages(user, *database.WorkspaceID)
|
||||
if err != nil || len(storages) == 0 {
|
||||
loadedStorages, err := storages.GetStorageService().GetStorages(user, *database.WorkspaceID)
|
||||
if err != nil || len(loadedStorages) == 0 {
|
||||
panic("No storage found for workspace")
|
||||
}
|
||||
|
||||
// Filter out system storages
|
||||
var nonSystemStorages []*storages.Storage
|
||||
for _, storage := range loadedStorages {
|
||||
if !storage.IsSystem {
|
||||
nonSystemStorages = append(nonSystemStorages, storage)
|
||||
}
|
||||
}
|
||||
if len(nonSystemStorages) == 0 {
|
||||
panic("No non-system storage found for workspace")
|
||||
}
|
||||
|
||||
storages := nonSystemStorages
|
||||
|
||||
backup := &backups_core.Backup{
|
||||
ID: uuid.New(),
|
||||
DatabaseID: database.ID,
|
||||
@@ -1320,7 +1444,7 @@ func Test_BandwidthThrottling_SingleDownload_Uses75Percent(t *testing.T) {
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
database, backup, storage := createTestDatabaseWithBackups(workspace, owner, router)
|
||||
|
||||
bandwidthManager := backups_download.GetBandwidthManager()
|
||||
initialCount := bandwidthManager.GetActiveDownloadCount()
|
||||
@@ -1370,6 +1494,12 @@ func Test_BandwidthThrottling_SingleDownload_Uses75Percent(t *testing.T) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
finalCount := bandwidthManager.GetActiveDownloadCount()
|
||||
assert.Equal(t, initialCount, finalCount, "Download should be unregistered after completion")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_BandwidthThrottling_MultipleDownloads_ShareBandwidth(t *testing.T) {
|
||||
@@ -1489,6 +1619,12 @@ func Test_BandwidthThrottling_MultipleDownloads_ShareBandwidth(t *testing.T) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
finalCount := bandwidthManager.GetActiveDownloadCount()
|
||||
assert.Equal(t, initialCount, finalCount, "All downloads should be unregistered")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_BandwidthThrottling_DynamicAdjustment(t *testing.T) {
|
||||
@@ -1577,4 +1713,10 @@ func Test_BandwidthThrottling_DynamicAdjustment(t *testing.T) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
finalCount := bandwidthManager.GetActiveDownloadCount()
|
||||
assert.Equal(t, initialCount, finalCount, "All downloads completed and unregistered")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type BackupConfigController struct {
|
||||
|
||||
func (c *BackupConfigController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.POST("/backup-configs/save", c.SaveBackupConfig)
|
||||
router.GET("/backup-configs/database/:id/plan", c.GetDatabasePlan)
|
||||
router.GET("/backup-configs/database/:id", c.GetBackupConfigByDbID)
|
||||
router.GET("/backup-configs/storage/:id/is-using", c.IsStorageUsing)
|
||||
router.GET("/backup-configs/storage/:id/databases-count", c.CountDatabasesForStorage)
|
||||
@@ -92,6 +93,39 @@ func (c *BackupConfigController) GetBackupConfigByDbID(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, backupConfig)
|
||||
}
|
||||
|
||||
// GetDatabasePlan
|
||||
// @Summary Get database plan by database ID
|
||||
// @Description Get the plan limits for a specific database (max backup size, max total size, max storage period)
|
||||
// @Tags backup-configs
|
||||
// @Produce json
|
||||
// @Param id path string true "Database ID"
|
||||
// @Success 200 {object} plans.DatabasePlan
|
||||
// @Failure 400 {object} map[string]string "Invalid database ID"
|
||||
// @Failure 401 {object} map[string]string "User not authenticated"
|
||||
// @Failure 404 {object} map[string]string "Database not found or access denied"
|
||||
// @Router /backup-configs/database/{id}/plan [get]
|
||||
func (c *BackupConfigController) GetDatabasePlan(ctx *gin.Context) {
|
||||
user, ok := users_middleware.GetUserFromContext(ctx)
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(ctx.Param("id"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := c.backupConfigService.GetDatabasePlan(user, id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "database plan not found"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, plan)
|
||||
}
|
||||
|
||||
// IsStorageUsing
|
||||
// @Summary Check if storage is being used
|
||||
// @Description Check if a storage is currently being used by any backup configuration
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -16,11 +17,14 @@ import (
|
||||
"databasus-backend/internal/features/databases/databases/postgresql"
|
||||
"databasus-backend/internal/features/intervals"
|
||||
"databasus-backend/internal/features/notifiers"
|
||||
plans "databasus-backend/internal/features/plan"
|
||||
"databasus-backend/internal/features/storages"
|
||||
local_storage "databasus-backend/internal/features/storages/models/local"
|
||||
users_enums "databasus-backend/internal/features/users/enums"
|
||||
users_testing "databasus-backend/internal/features/users/testing"
|
||||
workspaces_controllers "databasus-backend/internal/features/workspaces/controllers"
|
||||
workspaces_testing "databasus-backend/internal/features/workspaces/testing"
|
||||
"databasus-backend/internal/storage"
|
||||
"databasus-backend/internal/util/period"
|
||||
test_utils "databasus-backend/internal/util/testing"
|
||||
"databasus-backend/internal/util/tools"
|
||||
@@ -89,6 +93,11 @@ func Test_SaveBackupConfig_PermissionsEnforced(t *testing.T) {
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
@@ -152,6 +161,11 @@ func Test_SaveBackupConfig_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *test
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
timeOfDay := "04:00"
|
||||
@@ -242,6 +256,11 @@ func Test_GetBackupConfigByDbID_PermissionsEnforced(t *testing.T) {
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
@@ -290,6 +309,11 @@ func Test_GetBackupConfigByDbID_ReturnsDefaultConfigForNewDatabase(t *testing.T)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var response BackupConfig
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
@@ -300,14 +324,214 @@ func Test_GetBackupConfigByDbID_ReturnsDefaultConfigForNewDatabase(t *testing.T)
|
||||
&response,
|
||||
)
|
||||
|
||||
var plan plans.DatabasePlan
|
||||
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/database/"+database.ID.String()+"/plan",
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&plan,
|
||||
)
|
||||
|
||||
assert.Equal(t, database.ID, response.DatabaseID)
|
||||
assert.False(t, response.IsBackupsEnabled)
|
||||
assert.Equal(t, period.PeriodWeek, response.StorePeriod)
|
||||
assert.Equal(t, plan.MaxStoragePeriod, response.StorePeriod)
|
||||
assert.Equal(t, plan.MaxBackupSizeMB, response.MaxBackupSizeMB)
|
||||
assert.Equal(t, plan.MaxBackupsTotalSizeMB, response.MaxBackupsTotalSizeMB)
|
||||
assert.True(t, response.IsRetryIfFailed)
|
||||
assert.Equal(t, 3, response.MaxFailedTriesCount)
|
||||
assert.NotNil(t, response.BackupInterval)
|
||||
}
|
||||
|
||||
func Test_GetDatabasePlan_ForNewDatabase_PlanAlwaysReturned(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var response plans.DatabasePlan
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/database/"+database.ID.String()+"/plan",
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&response,
|
||||
)
|
||||
|
||||
assert.Equal(t, database.ID, response.DatabaseID)
|
||||
assert.NotNil(t, response.MaxBackupSizeMB)
|
||||
assert.NotNil(t, response.MaxBackupsTotalSizeMB)
|
||||
assert.NotEmpty(t, response.MaxStoragePeriod)
|
||||
}
|
||||
|
||||
func Test_SaveBackupConfig_WhenPlanLimitsAreAdjusted_ValidationEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
// Get plan via API (triggers auto-creation)
|
||||
var plan plans.DatabasePlan
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/database/"+database.ID.String()+"/plan",
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&plan,
|
||||
)
|
||||
|
||||
assert.Equal(t, database.ID, plan.DatabaseID)
|
||||
|
||||
// Adjust plan limits directly in database to fixed restrictive values
|
||||
err := storage.GetDb().Model(&plans.DatabasePlan{}).
|
||||
Where("database_id = ?", database.ID).
|
||||
Updates(map[string]any{
|
||||
"max_backup_size_mb": 100,
|
||||
"max_backups_total_size_mb": 1000,
|
||||
"max_storage_period": period.PeriodMonth,
|
||||
}).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test 1: Try to save backup config with exceeded backup size limit
|
||||
timeOfDay := "04:00"
|
||||
backupConfigExceededSize := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
MaxBackupSizeMB: 200, // Exceeds limit of 100
|
||||
MaxBackupsTotalSizeMB: 800,
|
||||
}
|
||||
|
||||
respExceededSize := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner.Token,
|
||||
backupConfigExceededSize,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
assert.Contains(t, string(respExceededSize.Body), "max backup size exceeds plan limit")
|
||||
|
||||
// Test 2: Try to save backup config with exceeded total size limit
|
||||
backupConfigExceededTotal := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
MaxBackupSizeMB: 50,
|
||||
MaxBackupsTotalSizeMB: 2000, // Exceeds limit of 1000
|
||||
}
|
||||
|
||||
respExceededTotal := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner.Token,
|
||||
backupConfigExceededTotal,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
assert.Contains(t, string(respExceededTotal.Body), "max total backups size exceeds plan limit")
|
||||
|
||||
// Test 3: Try to save backup config with exceeded storage period limit
|
||||
backupConfigExceededPeriod := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodYear, // Exceeds limit of Month
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
MaxBackupSizeMB: 80,
|
||||
MaxBackupsTotalSizeMB: 800,
|
||||
}
|
||||
|
||||
respExceededPeriod := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner.Token,
|
||||
backupConfigExceededPeriod,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
assert.Contains(t, string(respExceededPeriod.Body), "storage period exceeds plan limit")
|
||||
|
||||
// Test 4: Save backup config within all limits - should succeed
|
||||
backupConfigValid := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodWeek, // Within Month limit
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
MaxBackupSizeMB: 80, // Within 100 limit
|
||||
MaxBackupsTotalSizeMB: 800, // Within 1000 limit
|
||||
}
|
||||
|
||||
var responseValid BackupConfig
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner.Token,
|
||||
backupConfigValid,
|
||||
http.StatusOK,
|
||||
&responseValid,
|
||||
)
|
||||
|
||||
assert.Equal(t, database.ID, responseValid.DatabaseID)
|
||||
assert.Equal(t, int64(80), responseValid.MaxBackupSizeMB)
|
||||
assert.Equal(t, int64(800), responseValid.MaxBackupsTotalSizeMB)
|
||||
assert.Equal(t, period.PeriodWeek, responseValid.StorePeriod)
|
||||
}
|
||||
|
||||
func Test_IsStorageUsing_PermissionsEnforced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -340,6 +564,10 @@ func Test_IsStorageUsing_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
var testUserToken string
|
||||
if tt.isStorageOwner {
|
||||
testUserToken = storageOwner.Token
|
||||
@@ -372,10 +600,6 @@ func Test_IsStorageUsing_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
assert.Contains(t, string(testResp.Body), "error")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -387,6 +611,11 @@ func Test_SaveBackupConfig_WithEncryptionNone_ConfigSaved(t *testing.T) {
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -426,6 +655,11 @@ func Test_SaveBackupConfig_WithEncryptionEncrypted_ConfigSaved(t *testing.T) {
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -536,6 +770,15 @@ func Test_TransferDatabase_PermissionsEnforced(t *testing.T) {
|
||||
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
// Cleanup in correct order to avoid foreign key violations
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond) // Wait for cascade delete of backup_config
|
||||
storages.RemoveTestStorage(targetStorage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
@@ -628,6 +871,12 @@ func Test_TransferDatabase_NonMemberInSourceWorkspace_CannotTransfer(t *testing.
|
||||
router,
|
||||
)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
request := TransferDatabaseRequest{
|
||||
TargetWorkspaceID: targetWorkspace.ID,
|
||||
}
|
||||
@@ -668,6 +917,12 @@ func Test_TransferDatabase_NonMemberInTargetWorkspace_CannotTransfer(t *testing.
|
||||
router,
|
||||
)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
request := TransferDatabaseRequest{
|
||||
TargetWorkspaceID: targetWorkspace.ID,
|
||||
}
|
||||
@@ -695,6 +950,13 @@ func Test_TransferDatabase_ToNewStorage_DatabaseTransferd(t *testing.T) {
|
||||
sourceStorage := createTestStorage(sourceWorkspace.ID)
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond) // Wait for cascading deletes
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -774,6 +1036,13 @@ func Test_TransferDatabase_WithExistingStorage_DatabaseAndStorageTransferd(t *te
|
||||
database := createTestDatabaseViaAPI("Test Database", sourceWorkspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(sourceWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond) // Wait for cascading deletes
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -863,6 +1132,14 @@ func Test_TransferDatabase_StorageHasOtherDBs_CannotTransfer(t *testing.T) {
|
||||
)
|
||||
storage := createTestStorage(sourceWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database1)
|
||||
databases.RemoveTestDatabase(database2)
|
||||
time.Sleep(200 * time.Millisecond) // Wait for cascading deletes
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest1 := BackupConfig{
|
||||
DatabaseID: database1.ID,
|
||||
@@ -945,6 +1222,14 @@ func Test_TransferDatabase_WithNotifiers_NotifiersTransferred(t *testing.T) {
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
notifier := notifiers.CreateTestNotifier(sourceWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
database.Notifiers = []notifiers.Notifier{*notifier}
|
||||
var updatedDatabase databases.Database
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
@@ -1048,6 +1333,15 @@ func Test_TransferDatabase_NotifierHasOtherDBs_NotifierSkipped(t *testing.T) {
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
sharedNotifier := notifiers.CreateTestNotifier(sourceWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database1)
|
||||
databases.RemoveTestDatabase(database2)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(sharedNotifier)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
database1.Notifiers = []notifiers.Notifier{*sharedNotifier}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
@@ -1160,6 +1454,16 @@ func Test_TransferDatabase_WithMultipleNotifiers_OnlyExclusiveOnesTransferred(t
|
||||
exclusiveNotifier := notifiers.CreateTestNotifier(sourceWorkspace.ID)
|
||||
sharedNotifier := notifiers.CreateTestNotifier(sourceWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database1)
|
||||
databases.RemoveTestDatabase(database2)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(exclusiveNotifier)
|
||||
notifiers.RemoveTestNotifier(sharedNotifier)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
database1.Notifiers = []notifiers.Notifier{*exclusiveNotifier, *sharedNotifier}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
@@ -1271,6 +1575,14 @@ func Test_TransferDatabase_WithTargetNotifiers_NotifiersAssigned(t *testing.T) {
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
targetNotifier := notifiers.CreateTestNotifier(targetWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(targetNotifier)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -1342,6 +1654,15 @@ func Test_TransferDatabase_TargetNotifierFromDifferentWorkspace_ReturnsBadReques
|
||||
targetStorage := createTestStorage(targetWorkspace.ID)
|
||||
wrongNotifier := notifiers.CreateTestNotifier(otherWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifiers.RemoveTestNotifier(wrongNotifier)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(otherWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -1399,6 +1720,14 @@ func Test_TransferDatabase_TargetStorageFromDifferentWorkspace_ReturnsBadRequest
|
||||
sourceStorage := createTestStorage(sourceWorkspace.ID)
|
||||
wrongStorage := createTestStorage(otherWorkspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
workspaces_testing.RemoveTestWorkspace(sourceWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(otherWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigRequest := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -1443,6 +1772,115 @@ func Test_TransferDatabase_TargetStorageFromDifferentWorkspace_ReturnsBadRequest
|
||||
assert.Contains(t, string(testResp.Body), "target storage does not belong to target workspace")
|
||||
}
|
||||
|
||||
func Test_SaveBackupConfig_WithSystemStorage_CanBeUsedByAnyDatabase(t *testing.T) {
|
||||
router := createTestRouterWithStorageForTransfer()
|
||||
|
||||
owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
|
||||
workspaceA := workspaces_testing.CreateTestWorkspace("Workspace A", owner1, router)
|
||||
workspaceB := workspaces_testing.CreateTestWorkspace("Workspace B", owner2, router)
|
||||
|
||||
databaseA := createTestDatabaseViaAPI("Database A", workspaceA.ID, owner1.Token, router)
|
||||
|
||||
// Test 1: Regular storage from workspace B cannot be used by database in workspace A
|
||||
regularStorageB := createTestStorage(workspaceB.ID)
|
||||
|
||||
timeOfDay := "04:00"
|
||||
backupConfigWithRegularStorage := BackupConfig{
|
||||
DatabaseID: databaseA.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
StorageID: ®ularStorageB.ID,
|
||||
Storage: regularStorageB,
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
}
|
||||
|
||||
respRegular := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner1.Token,
|
||||
backupConfigWithRegularStorage,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(respRegular.Body), "storage does not belong to the same workspace")
|
||||
|
||||
// Test 2: System storage from workspace B CAN be used by database in workspace A
|
||||
systemStorageB := &storages.Storage{
|
||||
WorkspaceID: workspaceB.ID,
|
||||
Type: storages.StorageTypeLocal,
|
||||
Name: "Test System Storage " + uuid.New().String(),
|
||||
IsSystem: true,
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
|
||||
var savedSystemStorage storages.Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*systemStorageB,
|
||||
http.StatusOK,
|
||||
&savedSystemStorage,
|
||||
)
|
||||
|
||||
assert.True(t, savedSystemStorage.IsSystem)
|
||||
|
||||
backupConfigWithSystemStorage := BackupConfig{
|
||||
DatabaseID: databaseA.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
StorageID: &savedSystemStorage.ID,
|
||||
Storage: &savedSystemStorage,
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
},
|
||||
IsRetryIfFailed: true,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
}
|
||||
|
||||
var savedConfig BackupConfig
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+owner1.Token,
|
||||
backupConfigWithSystemStorage,
|
||||
http.StatusOK,
|
||||
&savedConfig,
|
||||
)
|
||||
|
||||
assert.Equal(t, databaseA.ID, savedConfig.DatabaseID)
|
||||
assert.NotNil(t, savedConfig.StorageID)
|
||||
assert.Equal(t, savedSystemStorage.ID, *savedConfig.StorageID)
|
||||
assert.True(t, savedConfig.IsBackupsEnabled)
|
||||
|
||||
// Cleanup: database first (cascades to backup_config), then storages, then workspaces
|
||||
databases.RemoveTestDatabase(databaseA)
|
||||
storages.RemoveTestStorage(regularStorageB.ID)
|
||||
storages.RemoveTestStorage(savedSystemStorage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspaceA, router)
|
||||
workspaces_testing.RemoveTestWorkspace(workspaceB, router)
|
||||
}
|
||||
|
||||
func createTestDatabaseViaAPI(
|
||||
name string,
|
||||
workspaceID uuid.UUID,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"databasus-backend/internal/features/databases"
|
||||
"databasus-backend/internal/features/notifiers"
|
||||
plans "databasus-backend/internal/features/plan"
|
||||
"databasus-backend/internal/features/storages"
|
||||
workspaces_services "databasus-backend/internal/features/workspaces/services"
|
||||
"databasus-backend/internal/util/logger"
|
||||
@@ -18,6 +19,7 @@ var backupConfigService = &BackupConfigService{
|
||||
storages.GetStorageService(),
|
||||
notifiers.GetNotifierService(),
|
||||
workspaces_services.GetWorkspaceService(),
|
||||
plans.GetDatabasePlanService(),
|
||||
nil,
|
||||
}
|
||||
var backupConfigController = &BackupConfigController{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package backups_config
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/config"
|
||||
"databasus-backend/internal/features/intervals"
|
||||
plans "databasus-backend/internal/features/plan"
|
||||
"databasus-backend/internal/features/storages"
|
||||
"databasus-backend/internal/util/period"
|
||||
"errors"
|
||||
@@ -75,7 +77,7 @@ func (b *BackupConfig) AfterFind(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BackupConfig) Validate() error {
|
||||
func (b *BackupConfig) Validate(plan *plans.DatabasePlan) error {
|
||||
// Backup interval is required either as ID or as object
|
||||
if b.BackupIntervalID == uuid.Nil && b.BackupInterval == nil {
|
||||
return errors.New("backup interval is required")
|
||||
@@ -94,6 +96,12 @@ func (b *BackupConfig) Validate() error {
|
||||
return errors.New("encryption must be NONE or ENCRYPTED")
|
||||
}
|
||||
|
||||
if config.GetEnv().IsCloud {
|
||||
if b.Encryption != BackupEncryptionEncrypted {
|
||||
return errors.New("encryption is mandatory for cloud storage")
|
||||
}
|
||||
}
|
||||
|
||||
if b.MaxBackupSizeMB < 0 {
|
||||
return errors.New("max backup size must be non-negative")
|
||||
}
|
||||
@@ -102,6 +110,29 @@ func (b *BackupConfig) Validate() error {
|
||||
return errors.New("max backups total size must be non-negative")
|
||||
}
|
||||
|
||||
// Validate against plan limits
|
||||
// Check storage period limit
|
||||
if plan.MaxStoragePeriod != period.PeriodForever {
|
||||
if b.StorePeriod.CompareTo(plan.MaxStoragePeriod) > 0 {
|
||||
return errors.New("storage period exceeds plan limit")
|
||||
}
|
||||
}
|
||||
|
||||
// Check max backup size limit (0 in plan means unlimited)
|
||||
if plan.MaxBackupSizeMB > 0 {
|
||||
if b.MaxBackupSizeMB == 0 || b.MaxBackupSizeMB > plan.MaxBackupSizeMB {
|
||||
return errors.New("max backup size exceeds plan limit")
|
||||
}
|
||||
}
|
||||
|
||||
// Check max total backups size limit (0 in plan means unlimited)
|
||||
if plan.MaxBackupsTotalSizeMB > 0 {
|
||||
if b.MaxBackupsTotalSizeMB == 0 ||
|
||||
b.MaxBackupsTotalSizeMB > plan.MaxBackupsTotalSizeMB {
|
||||
return errors.New("max total backups size exceeds plan limit")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
391
backend/internal/features/backups/config/model_test.go
Normal file
391
backend/internal/features/backups/config/model_test.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package backups_config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"databasus-backend/internal/features/intervals"
|
||||
plans "databasus-backend/internal/features/plan"
|
||||
"databasus-backend/internal/util/period"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Validate_WhenStoragePeriodIsWeekAndPlanAllowsMonth_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodWeek
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodMonth
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenStoragePeriodIsYearAndPlanAllowsMonth_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodYear
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodMonth
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "storage period exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenStoragePeriodIsForeverAndPlanAllowsForever_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodForever
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodForever
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenStoragePeriodIsForeverAndPlanAllowsYear_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodForever
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodYear
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "storage period exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenStoragePeriodEqualsExactPlanLimit_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodMonth
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodMonth
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenBackupSize100MBAndPlanAllows500MB_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = 100
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupSizeMB = 500
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenBackupSize500MBAndPlanAllows100MB_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = 500
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupSizeMB = 100
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max backup size exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenBackupSizeIsUnlimitedAndPlanAllowsUnlimited_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupSizeMB = 0
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenBackupSizeIsUnlimitedAndPlanHas500MBLimit_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupSizeMB = 500
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max backup size exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenBackupSizeEqualsExactPlanLimit_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = 500
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupSizeMB = 500
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenTotalSize1GBAndPlanAllows5GB_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = 1000
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupsTotalSizeMB = 5000
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenTotalSize5GBAndPlanAllows1GB_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = 5000
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupsTotalSizeMB = 1000
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max total backups size exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenTotalSizeIsUnlimitedAndPlanAllowsUnlimited_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupsTotalSizeMB = 0
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenTotalSizeIsUnlimitedAndPlanHas1GBLimit_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupsTotalSizeMB = 1000
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max total backups size exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenTotalSizeEqualsExactPlanLimit_ValidationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = 5000
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxBackupsTotalSizeMB = 5000
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenAllLimitsAreUnlimitedInPlan_AnyConfigurationPasses(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodForever
|
||||
config.MaxBackupSizeMB = 0
|
||||
config.MaxBackupsTotalSizeMB = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_WhenMultipleLimitsExceeded_ValidationFailsWithFirstError(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = period.PeriodYear
|
||||
config.MaxBackupSizeMB = 500
|
||||
config.MaxBackupsTotalSizeMB = 5000
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = period.PeriodMonth
|
||||
plan.MaxBackupSizeMB = 100
|
||||
plan.MaxBackupsTotalSizeMB = 1000
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.Error(t, err)
|
||||
assert.EqualError(t, err, "storage period exceeds plan limit")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenConfigHasInvalidIntervalButPlanIsValid_ValidationFailsOnInterval(
|
||||
t *testing.T,
|
||||
) {
|
||||
config := createValidBackupConfig()
|
||||
config.BackupIntervalID = uuid.Nil
|
||||
config.BackupInterval = nil
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "backup interval is required")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenIntervalIsMissing_ValidationFailsRegardlessOfPlan(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.BackupIntervalID = uuid.Nil
|
||||
config.BackupInterval = nil
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "backup interval is required")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenRetryEnabledButMaxTriesIsZero_ValidationFailsRegardlessOfPlan(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.IsRetryIfFailed = true
|
||||
config.MaxFailedTriesCount = 0
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max failed tries count must be greater than 0")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenEncryptionIsInvalid_ValidationFailsRegardlessOfPlan(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.Encryption = "INVALID"
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "encryption must be NONE or ENCRYPTED")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenStoragePeriodIsEmpty_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = ""
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "store period is required")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenMaxBackupSizeIsNegative_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupSizeMB = -100
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max backup size must be non-negative")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenMaxTotalSizeIsNegative_ValidationFails(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.MaxBackupsTotalSizeMB = -1000
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
|
||||
err := config.Validate(plan)
|
||||
assert.EqualError(t, err, "max backups total size must be non-negative")
|
||||
}
|
||||
|
||||
func Test_Validate_WhenPlanLimitsAreAtBoundary_ValidationWorks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configPeriod period.Period
|
||||
planPeriod period.Period
|
||||
configSize int64
|
||||
planSize int64
|
||||
configTotal int64
|
||||
planTotal int64
|
||||
shouldSucceed bool
|
||||
}{
|
||||
{
|
||||
name: "all values just under limit",
|
||||
configPeriod: period.PeriodWeek,
|
||||
planPeriod: period.PeriodMonth,
|
||||
configSize: 99,
|
||||
planSize: 100,
|
||||
configTotal: 999,
|
||||
planTotal: 1000,
|
||||
shouldSucceed: true,
|
||||
},
|
||||
{
|
||||
name: "all values equal to limit",
|
||||
configPeriod: period.PeriodMonth,
|
||||
planPeriod: period.PeriodMonth,
|
||||
configSize: 100,
|
||||
planSize: 100,
|
||||
configTotal: 1000,
|
||||
planTotal: 1000,
|
||||
shouldSucceed: true,
|
||||
},
|
||||
{
|
||||
name: "period just over limit",
|
||||
configPeriod: period.Period3Month,
|
||||
planPeriod: period.PeriodMonth,
|
||||
configSize: 100,
|
||||
planSize: 100,
|
||||
configTotal: 1000,
|
||||
planTotal: 1000,
|
||||
shouldSucceed: false,
|
||||
},
|
||||
{
|
||||
name: "size just over limit",
|
||||
configPeriod: period.PeriodMonth,
|
||||
planPeriod: period.PeriodMonth,
|
||||
configSize: 101,
|
||||
planSize: 100,
|
||||
configTotal: 1000,
|
||||
planTotal: 1000,
|
||||
shouldSucceed: false,
|
||||
},
|
||||
{
|
||||
name: "total size just over limit",
|
||||
configPeriod: period.PeriodMonth,
|
||||
planPeriod: period.PeriodMonth,
|
||||
configSize: 100,
|
||||
planSize: 100,
|
||||
configTotal: 1001,
|
||||
planTotal: 1000,
|
||||
shouldSucceed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := createValidBackupConfig()
|
||||
config.StorePeriod = tt.configPeriod
|
||||
config.MaxBackupSizeMB = tt.configSize
|
||||
config.MaxBackupsTotalSizeMB = tt.configTotal
|
||||
|
||||
plan := createUnlimitedPlan()
|
||||
plan.MaxStoragePeriod = tt.planPeriod
|
||||
plan.MaxBackupSizeMB = tt.planSize
|
||||
plan.MaxBackupsTotalSizeMB = tt.planTotal
|
||||
|
||||
err := config.Validate(plan)
|
||||
if tt.shouldSucceed {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createValidBackupConfig() *BackupConfig {
|
||||
intervalID := uuid.New()
|
||||
return &BackupConfig{
|
||||
DatabaseID: uuid.New(),
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodMonth,
|
||||
BackupIntervalID: intervalID,
|
||||
BackupInterval: &intervals.Interval{ID: intervalID},
|
||||
SendNotificationsOn: []BackupNotificationType{},
|
||||
IsRetryIfFailed: false,
|
||||
MaxFailedTriesCount: 3,
|
||||
Encryption: BackupEncryptionNone,
|
||||
MaxBackupSizeMB: 100,
|
||||
MaxBackupsTotalSizeMB: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
func createUnlimitedPlan() *plans.DatabasePlan {
|
||||
return &plans.DatabasePlan{
|
||||
DatabaseID: uuid.New(),
|
||||
MaxBackupSizeMB: 0,
|
||||
MaxBackupsTotalSizeMB: 0,
|
||||
MaxStoragePeriod: period.PeriodForever,
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ func Test_AttachNotifierFromSameWorkspace_SuccessfullyAttached(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
database.Notifiers = []notifiers.Notifier{*notifier}
|
||||
|
||||
var response databases.Database
|
||||
@@ -55,6 +61,13 @@ func Test_AttachNotifierFromDifferentWorkspace_ReturnsForbidden(t *testing.T) {
|
||||
workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router)
|
||||
notifier := notifiers.CreateTestNotifier(workspace2.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
}()
|
||||
|
||||
database.Notifiers = []notifiers.Notifier{*notifier}
|
||||
|
||||
testResp := test_utils.MakePostRequest(
|
||||
@@ -77,6 +90,12 @@ func Test_DeleteNotifierWithAttachedDatabases_CannotDelete(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
database.Notifiers = []notifiers.Notifier{*notifier}
|
||||
|
||||
var response databases.Database
|
||||
@@ -114,6 +133,13 @@ func Test_TransferNotifierWithAttachedDatabase_CannotTransfer(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
notifier := notifiers.CreateTestNotifier(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
notifiers.RemoveTestNotifier(notifier)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
database.Notifiers = []notifiers.Notifier{*notifier}
|
||||
|
||||
var response databases.Database
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"databasus-backend/internal/features/databases"
|
||||
"databasus-backend/internal/features/intervals"
|
||||
"databasus-backend/internal/features/notifiers"
|
||||
plans "databasus-backend/internal/features/plan"
|
||||
"databasus-backend/internal/features/storages"
|
||||
users_models "databasus-backend/internal/features/users/models"
|
||||
workspaces_services "databasus-backend/internal/features/workspaces/services"
|
||||
"databasus-backend/internal/util/period"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -20,6 +20,7 @@ type BackupConfigService struct {
|
||||
storageService *storages.StorageService
|
||||
notifierService *notifiers.NotifierService
|
||||
workspaceService *workspaces_services.WorkspaceService
|
||||
databasePlanService *plans.DatabasePlanService
|
||||
|
||||
dbStorageChangeListener BackupConfigStorageChangeListener
|
||||
}
|
||||
@@ -45,7 +46,12 @@ func (s *BackupConfigService) SaveBackupConfigWithAuth(
|
||||
user *users_models.User,
|
||||
backupConfig *BackupConfig,
|
||||
) (*BackupConfig, error) {
|
||||
if err := backupConfig.Validate(); err != nil {
|
||||
plan, err := s.databasePlanService.GetDatabasePlan(backupConfig.DatabaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := backupConfig.Validate(plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -71,7 +77,7 @@ func (s *BackupConfigService) SaveBackupConfigWithAuth(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if storage.WorkspaceID != *database.WorkspaceID {
|
||||
if storage.WorkspaceID != *database.WorkspaceID && !storage.IsSystem {
|
||||
return nil, errors.New("storage does not belong to the same workspace as the database")
|
||||
}
|
||||
}
|
||||
@@ -82,7 +88,12 @@ func (s *BackupConfigService) SaveBackupConfigWithAuth(
|
||||
func (s *BackupConfigService) SaveBackupConfig(
|
||||
backupConfig *BackupConfig,
|
||||
) (*BackupConfig, error) {
|
||||
if err := backupConfig.Validate(); err != nil {
|
||||
plan, err := s.databasePlanService.GetDatabasePlan(backupConfig.DatabaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := backupConfig.Validate(plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -120,6 +131,18 @@ func (s *BackupConfigService) GetBackupConfigByDbIdWithAuth(
|
||||
return s.GetBackupConfigByDbId(databaseID)
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) GetDatabasePlan(
|
||||
user *users_models.User,
|
||||
databaseID uuid.UUID,
|
||||
) (*plans.DatabasePlan, error) {
|
||||
_, err := s.databaseService.GetDatabase(user, databaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.databasePlanService.GetDatabasePlan(databaseID)
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) GetBackupConfigByDbId(
|
||||
databaseID uuid.UUID,
|
||||
) (*BackupConfig, error) {
|
||||
@@ -194,12 +217,19 @@ func (s *BackupConfigService) CreateDisabledBackupConfig(databaseID uuid.UUID) e
|
||||
func (s *BackupConfigService) initializeDefaultConfig(
|
||||
databaseID uuid.UUID,
|
||||
) error {
|
||||
plan, err := s.databasePlanService.GetDatabasePlan(databaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
timeOfDay := "04:00"
|
||||
|
||||
_, err := s.backupConfigRepository.Save(&BackupConfig{
|
||||
DatabaseID: databaseID,
|
||||
IsBackupsEnabled: false,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
_, err = s.backupConfigRepository.Save(&BackupConfig{
|
||||
DatabaseID: databaseID,
|
||||
IsBackupsEnabled: false,
|
||||
StorePeriod: plan.MaxStoragePeriod,
|
||||
MaxBackupSizeMB: plan.MaxBackupSizeMB,
|
||||
MaxBackupsTotalSizeMB: plan.MaxBackupsTotalSizeMB,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
|
||||
@@ -27,6 +27,12 @@ func Test_AttachStorageFromSameWorkspace_SuccessfullyAttached(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -72,6 +78,13 @@ func Test_AttachStorageFromDifferentWorkspace_ReturnsForbidden(t *testing.T) {
|
||||
workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router)
|
||||
storage := createTestStorage(workspace2.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -110,6 +123,12 @@ func Test_DeleteStorageWithAttachedDatabases_CannotDelete(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
@@ -163,6 +182,13 @@ func Test_TransferStorageWithAttachedDatabase_CannotTransfer(t *testing.T) {
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
workspaces_testing.RemoveTestWorkspace(targetWorkspace, router)
|
||||
}()
|
||||
|
||||
timeOfDay := "04:00"
|
||||
request := BackupConfig{
|
||||
DatabaseID: database.ID,
|
||||
|
||||
@@ -25,80 +25,6 @@ import (
|
||||
"databasus-backend/internal/util/tools"
|
||||
)
|
||||
|
||||
func createTestRouter() *gin.Engine {
|
||||
router := workspaces_testing.CreateTestRouter(
|
||||
workspaces_controllers.GetWorkspaceController(),
|
||||
workspaces_controllers.GetMembershipController(),
|
||||
GetDatabaseController(),
|
||||
)
|
||||
return router
|
||||
}
|
||||
|
||||
func getTestPostgresConfig() *postgresql.PostgresqlDatabase {
|
||||
env := config.GetEnv()
|
||||
port, err := strconv.Atoi(env.TestPostgres16Port)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_POSTGRES_16_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
return &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
CpuCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func getTestMariadbConfig() *mariadb.MariadbDatabase {
|
||||
env := config.GetEnv()
|
||||
portStr := env.TestMariadb1011Port
|
||||
if portStr == "" {
|
||||
portStr = "33111"
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_MARIADB_1011_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
return &mariadb.MariadbDatabase{
|
||||
Version: tools.MariadbVersion1011,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
}
|
||||
}
|
||||
|
||||
func getTestMongodbConfig() *mongodb.MongodbDatabase {
|
||||
env := config.GetEnv()
|
||||
portStr := env.TestMongodb70Port
|
||||
if portStr == "" {
|
||||
portStr = "27070"
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_MONGODB_70_PORT: %v", err))
|
||||
}
|
||||
|
||||
return &mongodb.MongodbDatabase{
|
||||
Version: tools.MongodbVersion7,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "root",
|
||||
Password: "rootpassword",
|
||||
Database: "testdb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
CpuCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func Test_CreateDatabase_PermissionsEnforced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -142,6 +68,7 @@ func Test_CreateDatabase_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -180,6 +107,7 @@ func Test_CreateDatabase_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
|
||||
if tt.expectSuccess {
|
||||
defer RemoveTestDatabase(&response)
|
||||
assert.Equal(t, "Test Database", response.Name)
|
||||
assert.NotEqual(t, uuid.Nil, response.ID)
|
||||
} else {
|
||||
@@ -193,6 +121,7 @@ func Test_CreateDatabase_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testin
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
@@ -258,8 +187,10 @@ func Test_UpdateDatabase_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -305,8 +236,10 @@ func Test_UpdateDatabase_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testin
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
database.Name = "Hacked Name"
|
||||
@@ -366,6 +299,7 @@ func Test_DeleteDatabase_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
@@ -396,6 +330,7 @@ func Test_DeleteDatabase_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
|
||||
if !tt.expectSuccess {
|
||||
defer RemoveTestDatabase(database)
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
})
|
||||
@@ -439,8 +374,10 @@ func Test_GetDatabase_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
var testUser string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -517,9 +454,12 @@ func Test_GetDatabasesByWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
createTestDatabaseViaAPI("Database 1", workspace.ID, owner.Token, router)
|
||||
createTestDatabaseViaAPI("Database 2", workspace.ID, owner.Token, router)
|
||||
db1 := createTestDatabaseViaAPI("Database 1", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(db1)
|
||||
db2 := createTestDatabaseViaAPI("Database 2", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(db2)
|
||||
|
||||
var testUser string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -561,10 +501,14 @@ func Test_GetDatabasesByWorkspace_WhenMultipleDatabasesExist_ReturnsCorrectCount
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
createTestDatabaseViaAPI("Database 1", workspace.ID, owner.Token, router)
|
||||
createTestDatabaseViaAPI("Database 2", workspace.ID, owner.Token, router)
|
||||
createTestDatabaseViaAPI("Database 3", workspace.ID, owner.Token, router)
|
||||
db1 := createTestDatabaseViaAPI("Database 1", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(db1)
|
||||
db2 := createTestDatabaseViaAPI("Database 2", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(db2)
|
||||
db3 := createTestDatabaseViaAPI("Database 3", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(db3)
|
||||
|
||||
var response []Database
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
@@ -583,14 +527,19 @@ func Test_GetDatabasesByWorkspace_EnsuresCrossWorkspaceIsolation(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace1 := workspaces_testing.CreateTestWorkspace("Workspace 1", owner1, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
|
||||
owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace2 := workspaces_testing.CreateTestWorkspace("Workspace 2", owner2, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
|
||||
createTestDatabaseViaAPI("Workspace1 DB1", workspace1.ID, owner1.Token, router)
|
||||
createTestDatabaseViaAPI("Workspace1 DB2", workspace1.ID, owner1.Token, router)
|
||||
workspace1Db1 := createTestDatabaseViaAPI("Workspace1 DB1", workspace1.ID, owner1.Token, router)
|
||||
defer RemoveTestDatabase(workspace1Db1)
|
||||
workspace1Db2 := createTestDatabaseViaAPI("Workspace1 DB2", workspace1.ID, owner1.Token, router)
|
||||
defer RemoveTestDatabase(workspace1Db2)
|
||||
|
||||
createTestDatabaseViaAPI("Workspace2 DB1", workspace2.ID, owner2.Token, router)
|
||||
workspace2Db1 := createTestDatabaseViaAPI("Workspace2 DB1", workspace2.ID, owner2.Token, router)
|
||||
defer RemoveTestDatabase(workspace2Db1)
|
||||
|
||||
var workspace1Dbs []Database
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
@@ -667,8 +616,10 @@ func Test_CopyDatabase_PermissionsEnforced(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -700,6 +651,7 @@ func Test_CopyDatabase_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
|
||||
if tt.expectSuccess {
|
||||
defer RemoveTestDatabase(&response)
|
||||
assert.NotEqual(t, database.ID, response.ID)
|
||||
assert.Contains(t, response.Name, "(Copy)")
|
||||
} else {
|
||||
@@ -713,8 +665,10 @@ func Test_CopyDatabase_CopyStaysInSameWorkspace(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
var response Database
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
@@ -727,139 +681,14 @@ func Test_CopyDatabase_CopyStaysInSameWorkspace(t *testing.T) {
|
||||
&response,
|
||||
)
|
||||
|
||||
defer RemoveTestDatabase(&response)
|
||||
|
||||
assert.NotEqual(t, database.ID, response.ID)
|
||||
assert.Equal(t, "Test Database (Copy)", response.Name)
|
||||
assert.Equal(t, workspace.ID, *response.WorkspaceID)
|
||||
assert.Equal(t, database.Type, response.Type)
|
||||
}
|
||||
|
||||
func Test_TestConnection_PermissionsEnforced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isMember bool
|
||||
isGlobalAdmin bool
|
||||
expectAccessGranted bool
|
||||
expectedStatusCodeOnErr int
|
||||
}{
|
||||
{
|
||||
name: "workspace member can test connection",
|
||||
isMember: true,
|
||||
isGlobalAdmin: false,
|
||||
expectAccessGranted: true,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "non-member cannot test connection",
|
||||
isMember: false,
|
||||
isGlobalAdmin: false,
|
||||
expectAccessGranted: false,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "global admin can test connection",
|
||||
isMember: false,
|
||||
isGlobalAdmin: true,
|
||||
expectAccessGranted: true,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
|
||||
var testUser string
|
||||
if tt.isGlobalAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
testUser = admin.Token
|
||||
} else if tt.isMember {
|
||||
testUser = owner.Token
|
||||
} else {
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
testUser = nonMember.Token
|
||||
}
|
||||
|
||||
w := workspaces_testing.MakeAPIRequest(
|
||||
router,
|
||||
"POST",
|
||||
"/api/v1/databases/"+database.ID.String()+"/test-connection",
|
||||
"Bearer "+testUser,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
if tt.expectAccessGranted {
|
||||
assert.True(
|
||||
t,
|
||||
w.Code == http.StatusOK ||
|
||||
(w.Code == http.StatusBadRequest && strings.Contains(body, "connect")),
|
||||
"Expected 200 OK or 400 with connection error, got %d: %s",
|
||||
w.Code,
|
||||
body,
|
||||
)
|
||||
} else {
|
||||
assert.Equal(t, tt.expectedStatusCodeOnErr, w.Code)
|
||||
assert.Contains(t, body, "insufficient permissions")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createTestDatabaseViaAPI(
|
||||
name string,
|
||||
workspaceID uuid.UUID,
|
||||
token string,
|
||||
router *gin.Engine,
|
||||
) *Database {
|
||||
env := config.GetEnv()
|
||||
port, err := strconv.Atoi(env.TestPostgres16Port)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_POSTGRES_16_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
request := Database{
|
||||
Name: name,
|
||||
WorkspaceID: &workspaceID,
|
||||
Type: DatabaseTypePostgres,
|
||||
Postgresql: &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
CpuCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
w := workspaces_testing.MakeAPIRequest(
|
||||
router,
|
||||
"POST",
|
||||
"/api/v1/databases/create",
|
||||
"Bearer "+token,
|
||||
request,
|
||||
)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
panic(
|
||||
fmt.Sprintf("Failed to create database. Status: %d, Body: %s", w.Code, w.Body.String()),
|
||||
)
|
||||
}
|
||||
|
||||
var database Database
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &database); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &database
|
||||
}
|
||||
|
||||
func Test_CreateDatabase_PasswordIsEncryptedInDB(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
@@ -1141,3 +970,207 @@ func Test_DatabaseSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_TestConnection_PermissionsEnforced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isMember bool
|
||||
isGlobalAdmin bool
|
||||
expectAccessGranted bool
|
||||
expectedStatusCodeOnErr int
|
||||
}{
|
||||
{
|
||||
name: "workspace member can test connection",
|
||||
isMember: true,
|
||||
isGlobalAdmin: false,
|
||||
expectAccessGranted: true,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "non-member cannot test connection",
|
||||
isMember: false,
|
||||
isGlobalAdmin: false,
|
||||
expectAccessGranted: false,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "global admin can test connection",
|
||||
isMember: false,
|
||||
isGlobalAdmin: true,
|
||||
expectAccessGranted: true,
|
||||
expectedStatusCodeOnErr: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database := createTestDatabaseViaAPI("Test Database", workspace.ID, owner.Token, router)
|
||||
defer RemoveTestDatabase(database)
|
||||
|
||||
var testUser string
|
||||
if tt.isGlobalAdmin {
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
testUser = admin.Token
|
||||
} else if tt.isMember {
|
||||
testUser = owner.Token
|
||||
} else {
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
testUser = nonMember.Token
|
||||
}
|
||||
|
||||
w := workspaces_testing.MakeAPIRequest(
|
||||
router,
|
||||
"POST",
|
||||
"/api/v1/databases/"+database.ID.String()+"/test-connection",
|
||||
"Bearer "+testUser,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
if tt.expectAccessGranted {
|
||||
assert.True(
|
||||
t,
|
||||
w.Code == http.StatusOK ||
|
||||
(w.Code == http.StatusBadRequest && strings.Contains(body, "connect")),
|
||||
"Expected 200 OK or 400 with connection error, got %d: %s",
|
||||
w.Code,
|
||||
body,
|
||||
)
|
||||
} else {
|
||||
assert.Equal(t, tt.expectedStatusCodeOnErr, w.Code)
|
||||
assert.Contains(t, body, "insufficient permissions")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createTestDatabaseViaAPI(
|
||||
name string,
|
||||
workspaceID uuid.UUID,
|
||||
token string,
|
||||
router *gin.Engine,
|
||||
) *Database {
|
||||
env := config.GetEnv()
|
||||
port, err := strconv.Atoi(env.TestPostgres16Port)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_POSTGRES_16_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
request := Database{
|
||||
Name: name,
|
||||
WorkspaceID: &workspaceID,
|
||||
Type: DatabaseTypePostgres,
|
||||
Postgresql: &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
CpuCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
w := workspaces_testing.MakeAPIRequest(
|
||||
router,
|
||||
"POST",
|
||||
"/api/v1/databases/create",
|
||||
"Bearer "+token,
|
||||
request,
|
||||
)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
panic(
|
||||
fmt.Sprintf("Failed to create database. Status: %d, Body: %s", w.Code, w.Body.String()),
|
||||
)
|
||||
}
|
||||
|
||||
var database Database
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &database); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &database
|
||||
}
|
||||
|
||||
func createTestRouter() *gin.Engine {
|
||||
router := workspaces_testing.CreateTestRouter(
|
||||
workspaces_controllers.GetWorkspaceController(),
|
||||
workspaces_controllers.GetMembershipController(),
|
||||
GetDatabaseController(),
|
||||
)
|
||||
return router
|
||||
}
|
||||
|
||||
func getTestPostgresConfig() *postgresql.PostgresqlDatabase {
|
||||
env := config.GetEnv()
|
||||
port, err := strconv.Atoi(env.TestPostgres16Port)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_POSTGRES_16_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
return &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
CpuCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func getTestMariadbConfig() *mariadb.MariadbDatabase {
|
||||
env := config.GetEnv()
|
||||
portStr := env.TestMariadb1011Port
|
||||
if portStr == "" {
|
||||
portStr = "33111"
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_MARIADB_1011_PORT: %v", err))
|
||||
}
|
||||
|
||||
testDbName := "testdb"
|
||||
return &mariadb.MariadbDatabase{
|
||||
Version: tools.MariadbVersion1011,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Username: "testuser",
|
||||
Password: "testpassword",
|
||||
Database: &testDbName,
|
||||
}
|
||||
}
|
||||
|
||||
func getTestMongodbConfig() *mongodb.MongodbDatabase {
|
||||
env := config.GetEnv()
|
||||
portStr := env.TestMongodb70Port
|
||||
if portStr == "" {
|
||||
portStr = "27070"
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to parse TEST_MONGODB_70_PORT: %v", err))
|
||||
}
|
||||
|
||||
return &mongodb.MongodbDatabase{
|
||||
Version: tools.MongodbVersion7,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: &port,
|
||||
Username: "root",
|
||||
Password: "rootpassword",
|
||||
Database: "testdb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
CpuCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,13 @@ type MongodbDatabase struct {
|
||||
Version tools.MongodbVersion `json:"version" gorm:"type:text;not null"`
|
||||
|
||||
Host string `json:"host" gorm:"type:text;not null"`
|
||||
Port int `json:"port" gorm:"type:int;not null"`
|
||||
Port *int `json:"port" gorm:"type:int"`
|
||||
Username string `json:"username" gorm:"type:text;not null"`
|
||||
Password string `json:"password" gorm:"type:text;not null"`
|
||||
Database string `json:"database" gorm:"type:text;not null"`
|
||||
AuthDatabase string `json:"authDatabase" gorm:"type:text;not null;default:'admin'"`
|
||||
IsHttps bool `json:"isHttps" gorm:"type:boolean;default:false"`
|
||||
IsSrv bool `json:"isSrv" gorm:"column:is_srv;type:boolean;not null;default:false"`
|
||||
CpuCount int `json:"cpuCount" gorm:"column:cpu_count;type:int;not null;default:1"`
|
||||
}
|
||||
|
||||
@@ -43,9 +44,13 @@ func (m *MongodbDatabase) Validate() error {
|
||||
if m.Host == "" {
|
||||
return errors.New("host is required")
|
||||
}
|
||||
if m.Port == 0 {
|
||||
return errors.New("port is required")
|
||||
|
||||
if !m.IsSrv {
|
||||
if m.Port == nil || *m.Port == 0 {
|
||||
return errors.New("port is required for standard connections")
|
||||
}
|
||||
}
|
||||
|
||||
if m.Username == "" {
|
||||
return errors.New("username is required")
|
||||
}
|
||||
@@ -58,6 +63,7 @@ func (m *MongodbDatabase) Validate() error {
|
||||
if m.CpuCount <= 0 {
|
||||
return errors.New("cpu count must be greater than 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -125,6 +131,7 @@ func (m *MongodbDatabase) Update(incoming *MongodbDatabase) {
|
||||
m.Database = incoming.Database
|
||||
m.AuthDatabase = incoming.AuthDatabase
|
||||
m.IsHttps = incoming.IsHttps
|
||||
m.IsSrv = incoming.IsSrv
|
||||
m.CpuCount = incoming.CpuCount
|
||||
|
||||
if incoming.Password != "" {
|
||||
@@ -455,12 +462,29 @@ func (m *MongodbDatabase) buildConnectionURI(password string) string {
|
||||
tlsParams = "&tls=true&tlsInsecure=true"
|
||||
}
|
||||
|
||||
if m.IsSrv {
|
||||
return fmt.Sprintf(
|
||||
"mongodb+srv://%s:%s@%s/%s?authSource=%s&connectTimeoutMS=15000%s",
|
||||
url.QueryEscape(m.Username),
|
||||
url.QueryEscape(password),
|
||||
m.Host,
|
||||
m.Database,
|
||||
authDB,
|
||||
tlsParams,
|
||||
)
|
||||
}
|
||||
|
||||
port := 27017
|
||||
if m.Port != nil {
|
||||
port = *m.Port
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"mongodb://%s:%s@%s:%d/%s?authSource=%s&connectTimeoutMS=15000%s",
|
||||
url.QueryEscape(m.Username),
|
||||
url.QueryEscape(password),
|
||||
m.Host,
|
||||
m.Port,
|
||||
port,
|
||||
m.Database,
|
||||
authDB,
|
||||
tlsParams,
|
||||
@@ -479,12 +503,28 @@ func (m *MongodbDatabase) BuildMongodumpURI(password string) string {
|
||||
tlsParams = "&tls=true&tlsInsecure=true"
|
||||
}
|
||||
|
||||
if m.IsSrv {
|
||||
return fmt.Sprintf(
|
||||
"mongodb+srv://%s:%s@%s/?authSource=%s&connectTimeoutMS=15000%s",
|
||||
url.QueryEscape(m.Username),
|
||||
url.QueryEscape(password),
|
||||
m.Host,
|
||||
authDB,
|
||||
tlsParams,
|
||||
)
|
||||
}
|
||||
|
||||
port := 27017
|
||||
if m.Port != nil {
|
||||
port = *m.Port
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"mongodb://%s:%s@%s:%d/?authSource=%s&connectTimeoutMS=15000%s",
|
||||
url.QueryEscape(m.Username),
|
||||
url.QueryEscape(password),
|
||||
m.Host,
|
||||
m.Port,
|
||||
port,
|
||||
authDB,
|
||||
tlsParams,
|
||||
)
|
||||
|
||||
@@ -64,15 +64,17 @@ func Test_TestConnection_InsufficientPermissions_ReturnsError(t *testing.T) {
|
||||
|
||||
defer dropUserSafe(container.Client, limitedUsername, container.AuthDatabase)
|
||||
|
||||
port := container.Port
|
||||
mongodbModel := &MongodbDatabase{
|
||||
Version: tc.version,
|
||||
Host: container.Host,
|
||||
Port: container.Port,
|
||||
Port: &port,
|
||||
Username: limitedUsername,
|
||||
Password: limitedPassword,
|
||||
Database: container.Database,
|
||||
AuthDatabase: container.AuthDatabase,
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
CpuCount: 1,
|
||||
}
|
||||
|
||||
@@ -133,15 +135,17 @@ func Test_TestConnection_SufficientPermissions_Success(t *testing.T) {
|
||||
|
||||
defer dropUserSafe(container.Client, backupUsername, container.AuthDatabase)
|
||||
|
||||
port := container.Port
|
||||
mongodbModel := &MongodbDatabase{
|
||||
Version: tc.version,
|
||||
Host: container.Host,
|
||||
Port: container.Port,
|
||||
Port: &port,
|
||||
Username: backupUsername,
|
||||
Password: backupPassword,
|
||||
Database: container.Database,
|
||||
AuthDatabase: container.AuthDatabase,
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
CpuCount: 1,
|
||||
}
|
||||
|
||||
@@ -442,15 +446,17 @@ func connectToMongodbContainer(
|
||||
}
|
||||
|
||||
func createMongodbModel(container *MongodbContainer) *MongodbDatabase {
|
||||
port := container.Port
|
||||
return &MongodbDatabase{
|
||||
Version: container.Version,
|
||||
Host: container.Host,
|
||||
Port: container.Port,
|
||||
Port: &port,
|
||||
Username: container.Username,
|
||||
Password: container.Password,
|
||||
Database: container.Database,
|
||||
AuthDatabase: container.AuthDatabase,
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
CpuCount: 1,
|
||||
}
|
||||
}
|
||||
@@ -489,3 +495,157 @@ func assertWriteDenied(t *testing.T, err error) {
|
||||
strings.Contains(errStr, "permission denied"),
|
||||
"Expected authorization error, got: %v", err)
|
||||
}
|
||||
|
||||
func Test_BuildConnectionURI_WithSrvFormat_ReturnsCorrectUri(t *testing.T) {
|
||||
port := 27017
|
||||
model := &MongodbDatabase{
|
||||
Host: "cluster0.example.mongodb.net",
|
||||
Port: &port,
|
||||
Username: "testuser",
|
||||
Password: "testpass123",
|
||||
Database: "mydb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
IsSrv: true,
|
||||
}
|
||||
|
||||
uri := model.buildConnectionURI("testpass123")
|
||||
|
||||
assert.Contains(t, uri, "mongodb+srv://")
|
||||
assert.Contains(t, uri, "testuser")
|
||||
assert.Contains(t, uri, "testpass123")
|
||||
assert.Contains(t, uri, "cluster0.example.mongodb.net")
|
||||
assert.Contains(t, uri, "/mydb")
|
||||
assert.Contains(t, uri, "authSource=admin")
|
||||
assert.Contains(t, uri, "connectTimeoutMS=15000")
|
||||
assert.NotContains(t, uri, ":27017")
|
||||
}
|
||||
|
||||
func Test_BuildConnectionURI_WithStandardFormat_ReturnsCorrectUri(t *testing.T) {
|
||||
port := 27017
|
||||
model := &MongodbDatabase{
|
||||
Host: "localhost",
|
||||
Port: &port,
|
||||
Username: "testuser",
|
||||
Password: "testpass123",
|
||||
Database: "mydb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
}
|
||||
|
||||
uri := model.buildConnectionURI("testpass123")
|
||||
|
||||
assert.Contains(t, uri, "mongodb://")
|
||||
assert.Contains(t, uri, "testuser")
|
||||
assert.Contains(t, uri, "testpass123")
|
||||
assert.Contains(t, uri, "localhost:27017")
|
||||
assert.Contains(t, uri, "/mydb")
|
||||
assert.Contains(t, uri, "authSource=admin")
|
||||
assert.Contains(t, uri, "connectTimeoutMS=15000")
|
||||
assert.NotContains(t, uri, "mongodb+srv://")
|
||||
}
|
||||
|
||||
func Test_BuildConnectionURI_WithNullPort_UsesDefault(t *testing.T) {
|
||||
model := &MongodbDatabase{
|
||||
Host: "localhost",
|
||||
Port: nil,
|
||||
Username: "testuser",
|
||||
Password: "testpass123",
|
||||
Database: "mydb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
}
|
||||
|
||||
uri := model.buildConnectionURI("testpass123")
|
||||
|
||||
assert.Contains(t, uri, "localhost:27017")
|
||||
}
|
||||
|
||||
func Test_BuildMongodumpURI_WithSrvFormat_ReturnsCorrectUri(t *testing.T) {
|
||||
port := 27017
|
||||
model := &MongodbDatabase{
|
||||
Host: "cluster0.example.mongodb.net",
|
||||
Port: &port,
|
||||
Username: "testuser",
|
||||
Password: "testpass123",
|
||||
Database: "mydb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
IsSrv: true,
|
||||
}
|
||||
|
||||
uri := model.BuildMongodumpURI("testpass123")
|
||||
|
||||
assert.Contains(t, uri, "mongodb+srv://")
|
||||
assert.Contains(t, uri, "testuser")
|
||||
assert.Contains(t, uri, "testpass123")
|
||||
assert.Contains(t, uri, "cluster0.example.mongodb.net")
|
||||
assert.Contains(t, uri, "/?authSource=admin")
|
||||
assert.Contains(t, uri, "connectTimeoutMS=15000")
|
||||
assert.NotContains(t, uri, ":27017")
|
||||
assert.NotContains(t, uri, "/mydb")
|
||||
}
|
||||
|
||||
func Test_BuildMongodumpURI_WithStandardFormat_ReturnsCorrectUri(t *testing.T) {
|
||||
port := 27017
|
||||
model := &MongodbDatabase{
|
||||
Host: "localhost",
|
||||
Port: &port,
|
||||
Username: "testuser",
|
||||
Password: "testpass123",
|
||||
Database: "mydb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
}
|
||||
|
||||
uri := model.BuildMongodumpURI("testpass123")
|
||||
|
||||
assert.Contains(t, uri, "mongodb://")
|
||||
assert.Contains(t, uri, "testuser")
|
||||
assert.Contains(t, uri, "testpass123")
|
||||
assert.Contains(t, uri, "localhost:27017")
|
||||
assert.Contains(t, uri, "/?authSource=admin")
|
||||
assert.Contains(t, uri, "connectTimeoutMS=15000")
|
||||
assert.NotContains(t, uri, "mongodb+srv://")
|
||||
assert.NotContains(t, uri, "/mydb")
|
||||
}
|
||||
|
||||
func Test_Validate_SrvConnection_AllowsNullPort(t *testing.T) {
|
||||
model := &MongodbDatabase{
|
||||
Host: "cluster0.example.mongodb.net",
|
||||
Port: nil,
|
||||
Username: "testuser",
|
||||
Password: "testpass123",
|
||||
Database: "mydb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
IsSrv: true,
|
||||
CpuCount: 1,
|
||||
}
|
||||
|
||||
err := model.Validate()
|
||||
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_Validate_StandardConnection_RequiresPort(t *testing.T) {
|
||||
model := &MongodbDatabase{
|
||||
Host: "localhost",
|
||||
Port: nil,
|
||||
Username: "testuser",
|
||||
Password: "testpass123",
|
||||
Database: "mydb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
CpuCount: 1,
|
||||
}
|
||||
|
||||
err := model.Validate()
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "port is required for standard connections")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package databases
|
||||
|
||||
import (
|
||||
"context"
|
||||
"databasus-backend/internal/features/databases/databases/mariadb"
|
||||
"databasus-backend/internal/features/databases/databases/mongodb"
|
||||
"databasus-backend/internal/features/databases/databases/mysql"
|
||||
@@ -84,6 +85,25 @@ func (d *Database) TestConnection(
|
||||
return d.getSpecificDatabase().TestConnection(logger, encryptor, d.ID)
|
||||
}
|
||||
|
||||
func (d *Database) IsUserReadOnly(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
) (bool, []string, error) {
|
||||
switch d.Type {
|
||||
case DatabaseTypePostgres:
|
||||
return d.Postgresql.IsUserReadOnly(ctx, logger, encryptor, d.ID)
|
||||
case DatabaseTypeMysql:
|
||||
return d.Mysql.IsUserReadOnly(ctx, logger, encryptor, d.ID)
|
||||
case DatabaseTypeMariadb:
|
||||
return d.Mariadb.IsUserReadOnly(ctx, logger, encryptor, d.ID)
|
||||
case DatabaseTypeMongodb:
|
||||
return d.Mongodb.IsUserReadOnly(ctx, logger, encryptor, d.ID)
|
||||
default:
|
||||
return false, nil, errors.New("read-only check not supported for this database type")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) HideSensitiveData() {
|
||||
d.getSpecificDatabase().HideSensitiveData()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"databasus-backend/internal/config"
|
||||
audit_logs "databasus-backend/internal/features/audit_logs"
|
||||
"databasus-backend/internal/features/databases/databases/mariadb"
|
||||
"databasus-backend/internal/features/databases/databases/mongodb"
|
||||
@@ -86,6 +87,23 @@ func (s *DatabaseService) CreateDatabase(
|
||||
return nil, fmt.Errorf("failed to auto-detect database data: %w", err)
|
||||
}
|
||||
|
||||
if config.GetEnv().IsCloud {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
isReadOnly, permissions, err := database.IsUserReadOnly(ctx, s.logger, s.fieldEncryptor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify user permissions: %w", err)
|
||||
}
|
||||
|
||||
if !isReadOnly {
|
||||
return nil, fmt.Errorf(
|
||||
"in cloud mode, only read-only database users are allowed (user has permissions: %v)",
|
||||
permissions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.EncryptSensitiveFields(s.fieldEncryptor); err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt sensitive fields: %w", err)
|
||||
}
|
||||
@@ -153,6 +171,27 @@ func (s *DatabaseService) UpdateDatabase(
|
||||
return fmt.Errorf("failed to auto-detect database data: %w", err)
|
||||
}
|
||||
|
||||
if config.GetEnv().IsCloud {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
isReadOnly, permissions, err := existingDatabase.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify user permissions: %w", err)
|
||||
}
|
||||
|
||||
if !isReadOnly {
|
||||
return fmt.Errorf(
|
||||
"in cloud mode, only read-only database users are allowed (user has permissions: %v)",
|
||||
permissions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := existingDatabase.EncryptSensitiveFields(s.fieldEncryptor); err != nil {
|
||||
return fmt.Errorf("failed to encrypt sensitive fields: %w", err)
|
||||
}
|
||||
@@ -649,38 +688,7 @@ func (s *DatabaseService) IsUserReadOnly(
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
switch usingDatabase.Type {
|
||||
case DatabaseTypePostgres:
|
||||
return usingDatabase.Postgresql.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
usingDatabase.ID,
|
||||
)
|
||||
case DatabaseTypeMysql:
|
||||
return usingDatabase.Mysql.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
usingDatabase.ID,
|
||||
)
|
||||
case DatabaseTypeMariadb:
|
||||
return usingDatabase.Mariadb.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
usingDatabase.ID,
|
||||
)
|
||||
case DatabaseTypeMongodb:
|
||||
return usingDatabase.Mongodb.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
usingDatabase.ID,
|
||||
)
|
||||
default:
|
||||
return false, nil, errors.New("read-only check not supported for this database type")
|
||||
}
|
||||
return usingDatabase.IsUserReadOnly(ctx, s.logger, s.fieldEncryptor)
|
||||
}
|
||||
|
||||
func (s *DatabaseService) CreateReadOnlyUser(
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"databasus-backend/internal/features/databases/databases/postgresql"
|
||||
"databasus-backend/internal/features/notifiers"
|
||||
"databasus-backend/internal/features/storages"
|
||||
"databasus-backend/internal/storage"
|
||||
"databasus-backend/internal/util/tools"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -70,12 +71,13 @@ func GetTestMongodbConfig() *mongodb.MongodbDatabase {
|
||||
return &mongodb.MongodbDatabase{
|
||||
Version: tools.MongodbVersion7,
|
||||
Host: config.GetEnv().TestLocalhost,
|
||||
Port: port,
|
||||
Port: &port,
|
||||
Username: "root",
|
||||
Password: "rootpassword",
|
||||
Database: "testdb",
|
||||
AuthDatabase: "admin",
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
CpuCount: 1,
|
||||
}
|
||||
}
|
||||
@@ -104,6 +106,19 @@ func CreateTestDatabase(
|
||||
}
|
||||
|
||||
func RemoveTestDatabase(database *Database) {
|
||||
// Delete backups and backup configs associated with this database
|
||||
// We hardcode SQL here because we cannot call backups feature due to DI inversion
|
||||
// (databases package cannot import backups package as backups already imports databases)
|
||||
db := storage.GetDb()
|
||||
|
||||
if err := db.Exec("DELETE FROM backups WHERE database_id = ?", database.ID).Error; err != nil {
|
||||
panic(fmt.Sprintf("failed to delete backups: %v", err))
|
||||
}
|
||||
|
||||
if err := db.Exec("DELETE FROM backup_configs WHERE database_id = ?", database.ID).Error; err != nil {
|
||||
panic(fmt.Sprintf("failed to delete backup config: %v", err))
|
||||
}
|
||||
|
||||
err := databaseRepository.Delete(database.ID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -12,6 +12,15 @@ import (
|
||||
type DiskService struct{}
|
||||
|
||||
func (s *DiskService) GetDiskUsage() (*DiskUsage, error) {
|
||||
if config.GetEnv().IsCloud {
|
||||
return &DiskUsage{
|
||||
Platform: PlatformLinux,
|
||||
TotalSpaceBytes: 100,
|
||||
UsedSpaceBytes: 0,
|
||||
FreeSpaceBytes: 100,
|
||||
}, nil
|
||||
}
|
||||
|
||||
platform := s.detectPlatform()
|
||||
|
||||
var path string
|
||||
|
||||
22
backend/internal/features/email/di.go
Normal file
22
backend/internal/features/email/di.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/config"
|
||||
"databasus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var env = config.GetEnv()
|
||||
var log = logger.GetLogger()
|
||||
|
||||
var emailSMTPSender = &EmailSMTPSender{
|
||||
log,
|
||||
env.SMTPHost,
|
||||
env.SMTPPort,
|
||||
env.SMTPUser,
|
||||
env.SMTPPassword,
|
||||
env.SMTPHost != "" && env.SMTPPort != 0,
|
||||
}
|
||||
|
||||
func GetEmailSMTPSender() *EmailSMTPSender {
|
||||
return emailSMTPSender
|
||||
}
|
||||
245
backend/internal/features/email/email.go
Normal file
245
backend/internal/features/email/email.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ImplicitTLSPort = 465
|
||||
DefaultTimeout = 5 * time.Second
|
||||
DefaultHelloName = "localhost"
|
||||
MIMETypeHTML = "text/html"
|
||||
MIMECharsetUTF8 = "UTF-8"
|
||||
)
|
||||
|
||||
type EmailSMTPSender struct {
|
||||
logger *slog.Logger
|
||||
smtpHost string
|
||||
smtpPort int
|
||||
smtpUser string
|
||||
smtpPassword string
|
||||
isConfigured bool
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) SendEmail(to, subject, body string) error {
|
||||
if !s.isConfigured {
|
||||
s.logger.Warn("Skipping email send, SMTP not initialized", "to", to, "subject", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
from := s.smtpUser
|
||||
if from == "" {
|
||||
from = "noreply@" + s.smtpHost
|
||||
}
|
||||
|
||||
emailContent := s.buildEmailContent(to, subject, body, from)
|
||||
isAuthRequired := s.smtpUser != "" && s.smtpPassword != ""
|
||||
|
||||
if s.smtpPort == ImplicitTLSPort {
|
||||
return s.sendImplicitTLS(to, from, emailContent, isAuthRequired)
|
||||
}
|
||||
|
||||
return s.sendStartTLS(to, from, emailContent, isAuthRequired)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) buildEmailContent(to, subject, body, from string) []byte {
|
||||
// Encode Subject header using RFC 2047 to avoid SMTPUTF8 requirement
|
||||
encodedSubject := encodeRFC2047(subject)
|
||||
subjectHeader := fmt.Sprintf("Subject: %s\r\n", encodedSubject)
|
||||
dateHeader := fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
|
||||
mimeHeaders := fmt.Sprintf(
|
||||
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
|
||||
MIMETypeHTML,
|
||||
MIMECharsetUTF8,
|
||||
)
|
||||
|
||||
// Encode From header display name if it contains non-ASCII
|
||||
encodedFrom := encodeRFC2047(from)
|
||||
fromHeader := fmt.Sprintf("From: %s\r\n", encodedFrom)
|
||||
|
||||
toHeader := fmt.Sprintf("To: %s\r\n", to)
|
||||
|
||||
return []byte(fromHeader + toHeader + subjectHeader + dateHeader + mimeHeaders + body)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) sendImplicitTLS(
|
||||
to, from string,
|
||||
emailContent []byte,
|
||||
isAuthRequired bool,
|
||||
) error {
|
||||
createClient := func() (*smtp.Client, func(), error) {
|
||||
return s.createImplicitTLSClient()
|
||||
}
|
||||
|
||||
client, cleanup, err := s.authenticateWithRetry(createClient, isAuthRequired)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return s.sendEmail(client, to, from, emailContent)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) sendStartTLS(
|
||||
to, from string,
|
||||
emailContent []byte,
|
||||
isAuthRequired bool,
|
||||
) error {
|
||||
createClient := func() (*smtp.Client, func(), error) {
|
||||
return s.createStartTLSClient()
|
||||
}
|
||||
|
||||
client, cleanup, err := s.authenticateWithRetry(createClient, isAuthRequired)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return s.sendEmail(client, to, from, emailContent)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) createImplicitTLSClient() (*smtp.Client, func(), error) {
|
||||
addr := net.JoinHostPort(s.smtpHost, fmt.Sprintf("%d", s.smtpPort))
|
||||
tlsConfig := &tls.Config{ServerName: s.smtpHost}
|
||||
dialer := &net.Dialer{Timeout: DefaultTimeout}
|
||||
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, s.smtpHost)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
|
||||
return client, func() { _ = client.Quit() }, nil
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) createStartTLSClient() (*smtp.Client, func(), error) {
|
||||
addr := net.JoinHostPort(s.smtpHost, fmt.Sprintf("%d", s.smtpPort))
|
||||
dialer := &net.Dialer{Timeout: DefaultTimeout}
|
||||
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, s.smtpHost)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Hello(DefaultHelloName); err != nil {
|
||||
_ = client.Quit()
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("SMTP hello failed: %w", err)
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: s.smtpHost}); err != nil {
|
||||
_ = client.Quit()
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("STARTTLS failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return client, func() { _ = client.Quit() }, nil
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) authenticateWithRetry(
|
||||
createClient func() (*smtp.Client, func(), error),
|
||||
isAuthRequired bool,
|
||||
) (*smtp.Client, func(), error) {
|
||||
client, cleanup, err := createClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !isAuthRequired {
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
// Try PLAIN auth first
|
||||
plainAuth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
|
||||
if err := client.Auth(plainAuth); err == nil {
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
// PLAIN auth failed, connection may be closed - recreate and try LOGIN auth
|
||||
cleanup()
|
||||
|
||||
client, cleanup, err = createClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
loginAuth := &loginAuth{username: s.smtpUser, password: s.smtpPassword}
|
||||
if err := client.Auth(loginAuth); err != nil {
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) sendEmail(client *smtp.Client, to, from string, content []byte) error {
|
||||
if err := client.Mail(from); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
|
||||
if _, err = writer.Write(content); err != nil {
|
||||
return fmt.Errorf("failed to write email content: %w", err)
|
||||
}
|
||||
|
||||
if err = writer.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeRFC2047(s string) string {
|
||||
return mime.QEncoding.Encode("UTF-8", s)
|
||||
}
|
||||
|
||||
type loginAuth struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:", "User Name\x00":
|
||||
return []byte(a.username), nil
|
||||
case "Password:", "Password\x00":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return []byte(a.username), nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -144,6 +144,10 @@ func Test_GetAttemptsByDatabase_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
assert.Contains(t, string(testResp.Body), "forbidden")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -181,6 +185,10 @@ func Test_GetAttemptsByDatabase_FiltersByAfterDate(t *testing.T) {
|
||||
for _, attempt := range response {
|
||||
assert.True(t, attempt.CreatedAt.After(afterDate) || attempt.CreatedAt.Equal(afterDate))
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_GetAttemptsByDatabase_ReturnsEmptyListForNewDatabase(t *testing.T) {
|
||||
@@ -201,6 +209,10 @@ func Test_GetAttemptsByDatabase_ReturnsEmptyListForNewDatabase(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Equal(t, 0, len(response))
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func createTestDatabaseViaAPI(
|
||||
|
||||
@@ -130,6 +130,10 @@ func Test_SaveHealthcheckConfig_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -162,6 +166,10 @@ func Test_SaveHealthcheckConfig_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t
|
||||
)
|
||||
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_GetHealthcheckConfig_PermissionsEnforced(t *testing.T) {
|
||||
@@ -268,6 +276,10 @@ func Test_GetHealthcheckConfig_PermissionsEnforced(t *testing.T) {
|
||||
)
|
||||
assert.Contains(t, string(testResp.Body), "insufficient permissions")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -295,6 +307,10 @@ func Test_GetHealthcheckConfig_ReturnsDefaultConfigForNewDatabase(t *testing.T)
|
||||
assert.Equal(t, 1, response.IntervalMinutes)
|
||||
assert.Equal(t, 3, response.AttemptsBeforeConcideredAsDown)
|
||||
assert.Equal(t, 7, response.StoreAttemptsDays)
|
||||
|
||||
// Cleanup
|
||||
databases.RemoveTestDatabase(database)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func createTestDatabaseViaAPI(
|
||||
|
||||
@@ -130,6 +130,7 @@ func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte
|
||||
// This ensures compatibility with SMTP servers that don't support SMTPUTF8
|
||||
encodedSubject := encodeRFC2047(heading)
|
||||
subject := fmt.Sprintf("Subject: %s\r\n", encodedSubject)
|
||||
dateHeader := fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
|
||||
mimeHeaders := fmt.Sprintf(
|
||||
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
|
||||
@@ -143,7 +144,7 @@ func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte
|
||||
|
||||
toHeader := fmt.Sprintf("To: %s\r\n", e.TargetEmail)
|
||||
|
||||
return []byte(fromHeader + toHeader + subject + mimeHeaders + message)
|
||||
return []byte(fromHeader + toHeader + subject + dateHeader + mimeHeaders + message)
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) sendImplicitTLS(
|
||||
|
||||
20
backend/internal/features/plan/di.go
Normal file
20
backend/internal/features/plan/di.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package plans
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var databasePlanRepository = &DatabasePlanRepository{}
|
||||
|
||||
var databasePlanService = &DatabasePlanService{
|
||||
databasePlanRepository,
|
||||
logger.GetLogger(),
|
||||
}
|
||||
|
||||
func GetDatabasePlanService() *DatabasePlanService {
|
||||
return databasePlanService
|
||||
}
|
||||
|
||||
func GetDatabasePlanRepository() *DatabasePlanRepository {
|
||||
return databasePlanRepository
|
||||
}
|
||||
19
backend/internal/features/plan/model.go
Normal file
19
backend/internal/features/plan/model.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package plans
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/util/period"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DatabasePlan struct {
|
||||
DatabaseID uuid.UUID `json:"databaseId" gorm:"column:database_id;type:uuid;primaryKey;not null"`
|
||||
|
||||
MaxBackupSizeMB int64 `json:"maxBackupSizeMb" gorm:"column:max_backup_size_mb;type:int;not null"`
|
||||
MaxBackupsTotalSizeMB int64 `json:"maxBackupsTotalSizeMb" gorm:"column:max_backups_total_size_mb;type:int;not null"`
|
||||
MaxStoragePeriod period.Period `json:"maxStoragePeriod" gorm:"column:max_storage_period;type:text;not null"`
|
||||
}
|
||||
|
||||
func (p *DatabasePlan) TableName() string {
|
||||
return "database_plans"
|
||||
}
|
||||
27
backend/internal/features/plan/repository.go
Normal file
27
backend/internal/features/plan/repository.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package plans
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DatabasePlanRepository struct{}
|
||||
|
||||
func (r *DatabasePlanRepository) GetDatabasePlan(databaseID uuid.UUID) (*DatabasePlan, error) {
|
||||
var databasePlan DatabasePlan
|
||||
|
||||
if err := storage.GetDb().Where("database_id = ?", databaseID).First(&databasePlan).Error; err != nil {
|
||||
if err.Error() == "record not found" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &databasePlan, nil
|
||||
}
|
||||
|
||||
func (r *DatabasePlanRepository) CreateDatabasePlan(databasePlan *DatabasePlan) error {
|
||||
return storage.GetDb().Create(&databasePlan).Error
|
||||
}
|
||||
67
backend/internal/features/plan/service.go
Normal file
67
backend/internal/features/plan/service.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package plans
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/config"
|
||||
"databasus-backend/internal/util/period"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DatabasePlanService struct {
|
||||
databasePlanRepository *DatabasePlanRepository
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *DatabasePlanService) GetDatabasePlan(databaseID uuid.UUID) (*DatabasePlan, error) {
|
||||
plan, err := s.databasePlanRepository.GetDatabasePlan(databaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if plan == nil {
|
||||
s.logger.Info("no database plan found, creating default plan", "databaseID", databaseID)
|
||||
|
||||
defaultPlan := s.createDefaultDatabasePlan(databaseID)
|
||||
|
||||
err := s.databasePlanRepository.CreateDatabasePlan(defaultPlan)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to create default database plan", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return defaultPlan, nil
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (s *DatabasePlanService) createDefaultDatabasePlan(databaseID uuid.UUID) *DatabasePlan {
|
||||
var plan DatabasePlan
|
||||
|
||||
isCloud := config.GetEnv().IsCloud
|
||||
if isCloud {
|
||||
s.logger.Info("creating default database plan for cloud", "databaseID", databaseID)
|
||||
|
||||
// for playground we set limited storages enough to test,
|
||||
// but not too expensive to provide it for Databasus
|
||||
plan = DatabasePlan{
|
||||
DatabaseID: databaseID,
|
||||
MaxBackupSizeMB: 100, // ~ 1.5GB database
|
||||
MaxBackupsTotalSizeMB: 4000, // ~ 30 daily backups + 10 manual backups
|
||||
MaxStoragePeriod: period.PeriodWeek,
|
||||
}
|
||||
} else {
|
||||
s.logger.Info("creating default database plan for self hosted", "databaseID", databaseID)
|
||||
|
||||
// by default - everything is unlimited in self hosted mode
|
||||
plan = DatabasePlan{
|
||||
DatabaseID: databaseID,
|
||||
MaxBackupSizeMB: 0,
|
||||
MaxBackupsTotalSizeMB: 0,
|
||||
MaxStoragePeriod: period.PeriodForever,
|
||||
}
|
||||
}
|
||||
|
||||
return &plan
|
||||
}
|
||||
@@ -46,8 +46,10 @@ func Test_GetRestores_WhenUserIsWorkspaceMember_RestoresReturned(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
var restores []*restores_core.Restore
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
@@ -68,8 +70,10 @@ func Test_GetRestores_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testing.T
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
@@ -88,8 +92,10 @@ func Test_GetRestores_WhenUserIsGlobalAdmin_RestoresReturned(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
|
||||
@@ -114,8 +120,10 @@ func Test_RestoreBackup_WhenUserIsWorkspaceMember_RestoreInitiated(t *testing.T)
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
request := restores_core.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &postgresql.PostgresqlDatabase{
|
||||
@@ -143,8 +151,10 @@ func Test_RestoreBackup_WhenUserIsNotWorkspaceMember_ReturnsForbidden(t *testing
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
@@ -178,8 +188,10 @@ func Test_RestoreBackup_WithIsExcludeExtensions_FlagPassedCorrectly(t *testing.T
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
request := restores_core.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &postgresql.PostgresqlDatabase{
|
||||
@@ -212,8 +224,10 @@ func Test_RestoreBackup_AuditLogWritten(t *testing.T) {
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
request := restores_core.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &postgresql.PostgresqlDatabase{
|
||||
@@ -296,12 +310,16 @@ func Test_RestoreBackup_DiskSpaceValidation(t *testing.T) {
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var database *databases.Database
|
||||
var backup *backups_core.Backup
|
||||
var storage *storages.Storage
|
||||
var request restores_core.RestoreBackupRequest
|
||||
|
||||
if tc.dbType == databases.DatabaseTypePostgres {
|
||||
_, backup = createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup = createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
request = restores_core.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
@@ -319,7 +337,16 @@ func Test_RestoreBackup_DiskSpaceValidation(t *testing.T) {
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
database = mysqlDB
|
||||
storage = createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
// Cleanup in dependency order: backup -> database -> storage
|
||||
cleanupBackup(backup)
|
||||
databases.RemoveTestDatabase(mysqlDB)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
}()
|
||||
|
||||
configService := backups_config.GetBackupConfigService()
|
||||
config, err := configService.GetBackupConfigByDbId(mysqlDB.ID)
|
||||
@@ -332,6 +359,7 @@ func Test_RestoreBackup_DiskSpaceValidation(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
backup = createTestBackup(mysqlDB, owner)
|
||||
|
||||
request = restores_core.RestoreBackupRequest{
|
||||
MysqlDatabase: &mysql.MysqlDatabase{
|
||||
Version: tools.MysqlVersion80,
|
||||
@@ -519,8 +547,10 @@ func Test_RestoreBackup_WithParallelRestoreInProgress_ReturnsError(t *testing.T)
|
||||
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
_, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
database, backup := createTestDatabaseWithBackupForRestore(workspace, owner, router)
|
||||
defer cleanupDatabaseWithBackup(database, backup)
|
||||
|
||||
request := restores_core.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &postgresql.PostgresqlDatabase{
|
||||
@@ -741,3 +771,22 @@ func createTestBackup(
|
||||
|
||||
return backup
|
||||
}
|
||||
|
||||
func cleanupDatabaseWithBackup(database *databases.Database, backup *backups_core.Backup) {
|
||||
// Clean up in reverse dependency order
|
||||
cleanupBackup(backup)
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Clean up storage last (after database and backup are removed)
|
||||
configService := backups_config.GetBackupConfigService()
|
||||
config, err := configService.GetBackupConfigByDbId(database.ID)
|
||||
if err == nil && config.StorageID != nil {
|
||||
storages.RemoveTestStorage(*config.StorageID)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupBackup(backup *backups_core.Backup) {
|
||||
repo := &backups_core.BackupRepository{}
|
||||
repo.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package restores
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/config"
|
||||
audit_logs "databasus-backend/internal/features/audit_logs"
|
||||
"databasus-backend/internal/features/backups/backups"
|
||||
backups_core "databasus-backend/internal/features/backups/backups/core"
|
||||
@@ -127,6 +128,13 @@ func (s *RestoreService) RestoreBackupWithAuth(
|
||||
return err
|
||||
}
|
||||
|
||||
if config.GetEnv().IsCloud {
|
||||
// in cloud mode we use only single thread mode,
|
||||
// because otherwise we will exhaust local storage
|
||||
// space (instead of streaming from S3 directly to DB)
|
||||
requestDTO.PostgresqlDatabase.CpuCount = 1
|
||||
}
|
||||
|
||||
if err := s.validateVersionCompatibility(backupDatabase, requestDTO); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ func (uc *RestorePostgresqlBackupUsecase) Execute(
|
||||
return fmt.Errorf("target database name is required for pg_restore")
|
||||
}
|
||||
|
||||
// Validate CPU count constraint for cloud environments
|
||||
if config.GetEnv().IsCloud && pg.CpuCount > 1 {
|
||||
return fmt.Errorf(
|
||||
"parallel restore (CPU count > 1) is not supported in cloud mode due to storage constraints. Please use CPU count = 1",
|
||||
)
|
||||
}
|
||||
|
||||
pgBin := tools.GetPostgresqlExecutable(
|
||||
pg.Version,
|
||||
"pg_restore",
|
||||
|
||||
@@ -58,7 +58,8 @@ func (c *StorageController) SaveStorage(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.storageService.SaveStorage(user, request.WorkspaceID, &request); err != nil {
|
||||
if errors.Is(err, ErrInsufficientPermissionsToManageStorage) {
|
||||
if errors.Is(err, ErrInsufficientPermissionsToManageStorage) ||
|
||||
errors.Is(err, ErrLocalStorageNotAllowedInCloudMode) {
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -325,7 +326,11 @@ func (c *StorageController) TestStorageConnectionDirect(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.storageService.TestStorageConnectionDirect(&request); err != nil {
|
||||
if err := c.storageService.TestStorageConnectionDirect(user, &request); err != nil {
|
||||
if errors.Is(err, ErrLocalStorageNotAllowedInCloudMode) {
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,4 +33,13 @@ var (
|
||||
ErrStorageHasOtherAttachedDatabasesCannotTransfer = errors.New(
|
||||
"storage has other attached databases and cannot be transferred",
|
||||
)
|
||||
ErrSystemStorageCannotBeTransferred = errors.New(
|
||||
"system storage cannot be transferred between workspaces",
|
||||
)
|
||||
ErrSystemStorageCannotBeMadePrivate = errors.New(
|
||||
"system storage cannot be changed to non-system",
|
||||
)
|
||||
ErrLocalStorageNotAllowedInCloudMode = errors.New(
|
||||
"local storage can only be managed by administrators in cloud mode",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ type Storage struct {
|
||||
Type StorageType `json:"type" gorm:"column:type;not null;type:text"`
|
||||
Name string `json:"name" gorm:"column:name;not null;type:text"`
|
||||
LastSaveError *string `json:"lastSaveError" gorm:"column:last_save_error;type:text"`
|
||||
IsSystem bool `json:"isSystem" gorm:"column:is_system;not null;default:false"`
|
||||
|
||||
// specific storage
|
||||
LocalStorage *local_storage.LocalStorage `json:"localStorage" gorm:"foreignKey:StorageID"`
|
||||
@@ -86,6 +87,17 @@ func (s *Storage) HideSensitiveData() {
|
||||
s.getSpecificStorage().HideSensitiveData()
|
||||
}
|
||||
|
||||
func (s *Storage) HideAllData() {
|
||||
s.LocalStorage = nil
|
||||
s.S3Storage = nil
|
||||
s.GoogleDriveStorage = nil
|
||||
s.NASStorage = nil
|
||||
s.AzureBlobStorage = nil
|
||||
s.FTPStorage = nil
|
||||
s.SFTPStorage = nil
|
||||
s.RcloneStorage = nil
|
||||
}
|
||||
|
||||
func (s *Storage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
return s.getSpecificStorage().EncryptSensitiveData(encryptor)
|
||||
}
|
||||
@@ -93,6 +105,7 @@ func (s *Storage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) erro
|
||||
func (s *Storage) Update(incoming *Storage) {
|
||||
s.Name = incoming.Name
|
||||
s.Type = incoming.Type
|
||||
s.IsSystem = incoming.IsSystem
|
||||
|
||||
switch s.Type {
|
||||
case StorageTypeLocal:
|
||||
|
||||
@@ -165,7 +165,7 @@ func (r *StorageRepository) FindByWorkspaceID(workspaceID uuid.UUID) ([]*Storage
|
||||
Preload("FTPStorage").
|
||||
Preload("SFTPStorage").
|
||||
Preload("RcloneStorage").
|
||||
Where("workspace_id = ?", workspaceID).
|
||||
Where("workspace_id = ? OR is_system = TRUE", workspaceID).
|
||||
Order("name ASC").
|
||||
Find(&storages).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -3,7 +3,9 @@ package storages
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"databasus-backend/internal/config"
|
||||
audit_logs "databasus-backend/internal/features/audit_logs"
|
||||
users_enums "databasus-backend/internal/features/users/enums"
|
||||
users_models "databasus-backend/internal/features/users/models"
|
||||
workspaces_services "databasus-backend/internal/features/workspaces/services"
|
||||
"databasus-backend/internal/util/encryption"
|
||||
@@ -23,6 +25,32 @@ func (s *StorageService) SetStorageDatabaseCounter(storageDatabaseCounter Storag
|
||||
s.storageDatabaseCounter = storageDatabaseCounter
|
||||
}
|
||||
|
||||
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 storage.IsSystem && storage.WorkspaceID != workspaceID {
|
||||
// skip system storage from another workspace
|
||||
continue
|
||||
}
|
||||
|
||||
if storage.IsSystem && storage.WorkspaceID == workspaceID {
|
||||
return fmt.Errorf(
|
||||
"system storage cannot be deleted due to workspace deletion, please transfer or remove storage first",
|
||||
)
|
||||
}
|
||||
|
||||
if err := s.storageRepository.Delete(storage); err != nil {
|
||||
return fmt.Errorf("failed to delete storage %s: %w", storage.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StorageService) SaveStorage(
|
||||
user *users_models.User,
|
||||
workspaceID uuid.UUID,
|
||||
@@ -36,8 +64,18 @@ func (s *StorageService) SaveStorage(
|
||||
return ErrInsufficientPermissionsToManageStorage
|
||||
}
|
||||
|
||||
if config.GetEnv().IsCloud && storage.Type == StorageTypeLocal &&
|
||||
user.Role != users_enums.UserRoleAdmin {
|
||||
return ErrLocalStorageNotAllowedInCloudMode
|
||||
}
|
||||
|
||||
isUpdate := storage.ID != uuid.Nil
|
||||
|
||||
if storage.IsSystem && user.Role != users_enums.UserRoleAdmin {
|
||||
// only admin can manage system storage
|
||||
return ErrInsufficientPermissionsToManageStorage
|
||||
}
|
||||
|
||||
if isUpdate {
|
||||
existingStorage, err := s.storageRepository.FindByID(storage.ID)
|
||||
if err != nil {
|
||||
@@ -48,6 +86,10 @@ func (s *StorageService) SaveStorage(
|
||||
return ErrStorageDoesNotBelongToWorkspace
|
||||
}
|
||||
|
||||
if existingStorage.IsSystem && !storage.IsSystem {
|
||||
return ErrSystemStorageCannotBeMadePrivate
|
||||
}
|
||||
|
||||
existingStorage.Update(storage)
|
||||
|
||||
if err := existingStorage.EncryptSensitiveData(s.fieldEncryptor); err != nil {
|
||||
@@ -111,6 +153,11 @@ func (s *StorageService) DeleteStorage(
|
||||
return ErrInsufficientPermissionsToManageStorage
|
||||
}
|
||||
|
||||
if storage.IsSystem && user.Role != users_enums.UserRoleAdmin {
|
||||
// only admin can manage system storage
|
||||
return ErrInsufficientPermissionsToManageStorage
|
||||
}
|
||||
|
||||
attachedDatabasesIDs, err := s.storageDatabaseCounter.GetStorageAttachedDatabasesIDs(storage.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -142,16 +189,22 @@ func (s *StorageService) GetStorage(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
canView, _, err := s.workspaceService.CanUserAccessWorkspace(storage.WorkspaceID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canView {
|
||||
return nil, ErrInsufficientPermissionsToViewStorage
|
||||
if !storage.IsSystem {
|
||||
canView, _, err := s.workspaceService.CanUserAccessWorkspace(storage.WorkspaceID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canView {
|
||||
return nil, ErrInsufficientPermissionsToViewStorage
|
||||
}
|
||||
}
|
||||
|
||||
storage.HideSensitiveData()
|
||||
|
||||
if storage.IsSystem && user.Role != users_enums.UserRoleAdmin {
|
||||
storage.HideAllData()
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
@@ -174,6 +227,10 @@ func (s *StorageService) GetStorages(
|
||||
|
||||
for _, storage := range storages {
|
||||
storage.HideSensitiveData()
|
||||
|
||||
if storage.IsSystem && user.Role != users_enums.UserRoleAdmin {
|
||||
storage.HideAllData()
|
||||
}
|
||||
}
|
||||
|
||||
return storages, nil
|
||||
@@ -213,8 +270,14 @@ func (s *StorageService) TestStorageConnection(
|
||||
}
|
||||
|
||||
func (s *StorageService) TestStorageConnectionDirect(
|
||||
user *users_models.User,
|
||||
storage *Storage,
|
||||
) error {
|
||||
if config.GetEnv().IsCloud && storage.Type == StorageTypeLocal &&
|
||||
user.Role != users_enums.UserRoleAdmin {
|
||||
return ErrLocalStorageNotAllowedInCloudMode
|
||||
}
|
||||
|
||||
var usingStorage *Storage
|
||||
|
||||
if storage.ID != uuid.Nil {
|
||||
@@ -258,6 +321,10 @@ func (s *StorageService) TransferStorageToWorkspace(
|
||||
return err
|
||||
}
|
||||
|
||||
if existingStorage.IsSystem {
|
||||
return ErrSystemStorageCannotBeTransferred
|
||||
}
|
||||
|
||||
canManageSource, err := s.workspaceService.CanUserManageDBs(existingStorage.WorkspaceID, user)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -310,18 +377,3 @@ func (s *StorageService) TransferStorageToWorkspace(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@ func (c *HealthcheckController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
// @Failure 503 {object} HealthcheckResponse
|
||||
// @Router /system/health [get]
|
||||
func (c *HealthcheckController) CheckHealth(ctx *gin.Context) {
|
||||
// Allow unrestricted CORS for health check endpoint
|
||||
// This enables monitoring tools from any origin to check system health
|
||||
ctx.Header("Access-Control-Allow-Origin", "*")
|
||||
ctx.Header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
ctx.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if ctx.Request.Method == "OPTIONS" {
|
||||
ctx.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
err := c.healthcheckService.IsHealthy()
|
||||
|
||||
if err == nil {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package system_healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"databasus-backend/internal/config"
|
||||
"databasus-backend/internal/features/backups/backups/backuping"
|
||||
"databasus-backend/internal/features/disk"
|
||||
"databasus-backend/internal/storage"
|
||||
cache_utils "databasus-backend/internal/util/cache"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HealthcheckService struct {
|
||||
@@ -15,6 +18,20 @@ type HealthcheckService struct {
|
||||
}
|
||||
|
||||
func (s *HealthcheckService) IsHealthy() error {
|
||||
return s.performHealthCheck()
|
||||
}
|
||||
|
||||
func (s *HealthcheckService) performHealthCheck() error {
|
||||
// Check if cache is available with PING
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := cache_utils.GetValkeyClient()
|
||||
pingResult := client.Do(ctx, client.B().Ping().Build())
|
||||
if pingResult.Error() != nil {
|
||||
return errors.New("cannot connect to valkey")
|
||||
}
|
||||
|
||||
diskUsage, err := s.diskService.GetDiskUsage()
|
||||
if err != nil {
|
||||
return errors.New("cannot get disk usage")
|
||||
@@ -35,11 +52,16 @@ func (s *HealthcheckService) IsHealthy() error {
|
||||
if !s.backupBackgroundService.IsSchedulerRunning() {
|
||||
return errors.New("backups are not running for more than 5 minutes")
|
||||
}
|
||||
|
||||
if !s.backupBackgroundService.IsBackupNodesAvailable() {
|
||||
return errors.New("no backup nodes available")
|
||||
}
|
||||
}
|
||||
|
||||
if config.GetEnv().IsProcessingNode {
|
||||
if !s.backuperNode.IsBackuperRunning() {
|
||||
return errors.New("backuper node is not running for more than 5 minutes")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -385,13 +385,14 @@ func createMongodbDatabaseViaAPI(
|
||||
Type: databases.DatabaseTypeMongodb,
|
||||
Mongodb: &mongodbtypes.MongodbDatabase{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Port: &port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: database,
|
||||
AuthDatabase: authDatabase,
|
||||
Version: version,
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
CpuCount: 1,
|
||||
},
|
||||
}
|
||||
@@ -432,13 +433,14 @@ func createMongodbRestoreViaAPI(
|
||||
request := restores_core.RestoreBackupRequest{
|
||||
MongodbDatabase: &mongodbtypes.MongodbDatabase{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Port: &port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: database,
|
||||
AuthDatabase: authDatabase,
|
||||
Version: version,
|
||||
IsHttps: false,
|
||||
IsSrv: false,
|
||||
CpuCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
package users_controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
users_dto "databasus-backend/internal/features/users/dto"
|
||||
users_enums "databasus-backend/internal/features/users/enums"
|
||||
users_models "databasus-backend/internal/features/users/models"
|
||||
users_services "databasus-backend/internal/features/users/services"
|
||||
users_testing "databasus-backend/internal/features/users/testing"
|
||||
"databasus-backend/internal/storage"
|
||||
test_utils "databasus-backend/internal/util/testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func Test_SendResetPasswordCode_WithValidEmail_CodeSent(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
|
||||
assert.Equal(t, user.Email, mockEmailSender.SentEmails[0].To)
|
||||
assert.Contains(t, mockEmailSender.SentEmails[0].Subject, "Password Reset")
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithNonExistentUser_ReturnsSuccess(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: "nonexistent" + uuid.New().String() + "@example.com",
|
||||
}
|
||||
|
||||
// Should return success to prevent enumeration attacks
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// But no email should be sent
|
||||
assert.Equal(t, 0, len(mockEmailSender.SentEmails))
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithInvitedUser_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
email := "invited" + uuid.New().String() + "@example.com"
|
||||
|
||||
inviteRequest := users_dto.InviteUserRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/invite",
|
||||
"Bearer "+adminUser.Token,
|
||||
inviteRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "only active users")
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithRateLimitExceeded_ReturnsTooManyRequests(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
// Make 3 requests (should succeed)
|
||||
for range 3 {
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
// 4th request should be rate limited
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusTooManyRequests,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "Rate limit exceeded")
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithInvalidJSON_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
|
||||
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
|
||||
Method: "POST",
|
||||
URL: "/api/v1/users/send-reset-password-code",
|
||||
Body: "invalid json",
|
||||
ExpectedStatus: http.StatusBadRequest,
|
||||
})
|
||||
|
||||
assert.Contains(t, string(resp.Body), "Invalid request format")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithValidCode_PasswordReset(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
email := "resettest" + uuid.New().String() + "@example.com"
|
||||
oldPassword := "oldpassword123"
|
||||
newPassword := "newpassword456"
|
||||
|
||||
// Create user
|
||||
signupRequest := users_dto.SignUpRequestDTO{
|
||||
Email: email,
|
||||
Password: oldPassword,
|
||||
Name: "Test User",
|
||||
}
|
||||
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
|
||||
|
||||
// Request reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// Extract code from email
|
||||
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
|
||||
emailBody := mockEmailSender.SentEmails[0].Body
|
||||
code := extractCodeFromEmail(emailBody)
|
||||
t.Logf("Extracted code: %s from email body (length: %d)", code, len(code))
|
||||
assert.NotEmpty(t, code, "Code should be extracted from email")
|
||||
assert.Len(t, code, 6, "Code should be 6 digits")
|
||||
|
||||
// Reset password
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: newPassword,
|
||||
}
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Logf("Reset password failed with body: %s", string(resp.Body))
|
||||
}
|
||||
|
||||
// Verify old password doesn't work
|
||||
oldSigninRequest := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: oldPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
oldSigninRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
// Verify new password works
|
||||
newSigninRequest := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: newPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
newSigninRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithExpiredCode_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
// Create expired reset code directly in database
|
||||
code := "123456"
|
||||
hashedCode, _ := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
expiredCode := &users_models.PasswordResetCode{
|
||||
ID: uuid.New(),
|
||||
UserID: user.UserID,
|
||||
HashedCode: string(hashedCode),
|
||||
ExpiresAt: time.Now().UTC().Add(-1 * time.Hour), // Expired 1 hour ago
|
||||
IsUsed: false,
|
||||
CreatedAt: time.Now().UTC().Add(-2 * time.Hour),
|
||||
}
|
||||
storage.GetDb().Create(expiredCode)
|
||||
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: user.Email,
|
||||
Code: code,
|
||||
NewPassword: "newpassword123",
|
||||
}
|
||||
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "invalid or expired")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithUsedCode_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
email := "usedcode" + uuid.New().String() + "@example.com"
|
||||
|
||||
// Create user
|
||||
signupRequest := users_dto.SignUpRequestDTO{
|
||||
Email: email,
|
||||
Password: "password123",
|
||||
Name: "Test User",
|
||||
}
|
||||
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
|
||||
|
||||
// Request reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
code := extractCodeFromEmail(mockEmailSender.SentEmails[0].Body)
|
||||
|
||||
// Use code first time
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: "newpassword123",
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// Try to use same code again
|
||||
resetRequest2 := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: "anotherpassword456",
|
||||
}
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest2,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "invalid or expired")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithWrongCode_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
// Request reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// Try to reset with wrong code
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: user.Email,
|
||||
Code: "999999", // Wrong code
|
||||
NewPassword: "newpassword123",
|
||||
}
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "invalid")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithInvalidNewPassword_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: user.Email,
|
||||
Code: "123456",
|
||||
NewPassword: "short", // Too short
|
||||
}
|
||||
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
|
||||
func Test_ResetPassword_EmailSendFailure_ReturnsError(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
mockEmailSender.ShouldFail = true
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "failed to send email")
|
||||
}
|
||||
|
||||
func Test_ResetPasswordFlow_E2E_CompletesSuccessfully(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
email := "e2e" + uuid.New().String() + "@example.com"
|
||||
initialPassword := "initialpass123"
|
||||
newPassword := "brandnewpass456"
|
||||
|
||||
// 1. Create user via signup
|
||||
signupRequest := users_dto.SignUpRequestDTO{
|
||||
Email: email,
|
||||
Password: initialPassword,
|
||||
Name: "E2E Test User",
|
||||
}
|
||||
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
|
||||
|
||||
// 2. Verify can sign in with initial password
|
||||
signinRequest := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: initialPassword,
|
||||
}
|
||||
var signinResponse users_dto.SignInResponseDTO
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
signinRequest,
|
||||
http.StatusOK,
|
||||
&signinResponse,
|
||||
)
|
||||
assert.NotEmpty(t, signinResponse.Token)
|
||||
|
||||
// 3. Request password reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// 4. Verify email was sent
|
||||
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
|
||||
code := extractCodeFromEmail(mockEmailSender.SentEmails[0].Body)
|
||||
assert.NotEmpty(t, code)
|
||||
|
||||
// 5. Reset password using code
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: newPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// 6. Verify old password no longer works
|
||||
oldSignin := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: initialPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
oldSignin,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
// 7. Verify new password works
|
||||
newSignin := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: newPassword,
|
||||
}
|
||||
var finalResponse users_dto.SignInResponseDTO
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
newSignin,
|
||||
http.StatusOK,
|
||||
&finalResponse,
|
||||
)
|
||||
assert.NotEmpty(t, finalResponse.Token)
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithInvalidJSON_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
|
||||
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
|
||||
Method: "POST",
|
||||
URL: "/api/v1/users/reset-password",
|
||||
Body: "invalid json",
|
||||
ExpectedStatus: http.StatusBadRequest,
|
||||
})
|
||||
|
||||
assert.Contains(t, string(resp.Body), "Invalid request format")
|
||||
}
|
||||
|
||||
// Helper function to extract 6-digit code from email HTML body
|
||||
func extractCodeFromEmail(emailBody string) string {
|
||||
// Look for pattern: <h1 ... >CODE</h1>
|
||||
// First find <h1
|
||||
h1Start := 0
|
||||
for i := 0; i < len(emailBody)-3; i++ {
|
||||
if emailBody[i:i+3] == "<h1" {
|
||||
h1Start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if h1Start == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Find the > after <h1
|
||||
contentStart := h1Start
|
||||
for i := h1Start; i < len(emailBody); i++ {
|
||||
if emailBody[i] == '>' {
|
||||
contentStart = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Find </h1>
|
||||
contentEnd := contentStart
|
||||
for i := contentStart; i < len(emailBody)-5; i++ {
|
||||
if emailBody[i:i+5] == "</h1>" {
|
||||
contentEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if contentEnd <= contentStart {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract content and remove whitespace
|
||||
content := emailBody[contentStart:contentEnd]
|
||||
code := ""
|
||||
for i := 0; i < len(content); i++ {
|
||||
if isDigit(content[i]) {
|
||||
code += string(content[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(code) == 6 {
|
||||
return code
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDigit(b byte) bool {
|
||||
return b >= '0' && b <= '9'
|
||||
}
|
||||
@@ -28,6 +28,10 @@ func (c *UserController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/users/admin/has-password", c.IsAdminHasPassword)
|
||||
router.POST("/users/admin/set-password", c.SetAdminPassword)
|
||||
|
||||
// Password reset (no auth required)
|
||||
router.POST("/users/send-reset-password-code", c.SendResetPasswordCode)
|
||||
router.POST("/users/reset-password", c.ResetPassword)
|
||||
|
||||
// OAuth callbacks
|
||||
router.POST("/auth/github/callback", c.HandleGitHubOAuth)
|
||||
router.POST("/auth/google/callback", c.HandleGoogleOAuth)
|
||||
@@ -340,3 +344,70 @@ func (c *UserController) HandleGoogleOAuth(ctx *gin.Context) {
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// SendResetPasswordCode
|
||||
// @Summary Send password reset code
|
||||
// @Description Send a password reset code to the user's email
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body users_dto.SendResetPasswordCodeRequestDTO true "Email address"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 429 {object} map[string]string
|
||||
// @Router /users/send-reset-password-code [post]
|
||||
func (c *UserController) SendResetPasswordCode(ctx *gin.Context) {
|
||||
var request user_dto.SendResetPasswordCodeRequestDTO
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
return
|
||||
}
|
||||
|
||||
allowed, _ := c.rateLimiter.CheckLimit(
|
||||
request.Email,
|
||||
"reset-password",
|
||||
3,
|
||||
1*time.Hour,
|
||||
)
|
||||
if !allowed {
|
||||
ctx.JSON(
|
||||
http.StatusTooManyRequests,
|
||||
gin.H{"error": "Rate limit exceeded. Please try again later."},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
err := c.userService.SendResetPasswordCode(request.Email)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"message": "If the email exists, a reset code has been sent"})
|
||||
}
|
||||
|
||||
// ResetPassword
|
||||
// @Summary Reset password with code
|
||||
// @Description Reset user password using the code sent via email
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body users_dto.ResetPasswordRequestDTO true "Reset password data"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Router /users/reset-password [post]
|
||||
func (c *UserController) ResetPassword(ctx *gin.Context) {
|
||||
var request user_dto.ResetPasswordRequestDTO
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
return
|
||||
}
|
||||
|
||||
err := c.userService.ResetPassword(request.Email, request.Code, request.NewPassword)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
|
||||
}
|
||||
|
||||
@@ -92,3 +92,13 @@ type OAuthCallbackResponseDTO struct {
|
||||
Token string `json:"token"`
|
||||
IsNewUser bool `json:"isNewUser"`
|
||||
}
|
||||
|
||||
type SendResetPasswordCodeRequestDTO struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequestDTO struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
@@ -7,3 +7,7 @@ import (
|
||||
type AuditLogWriter interface {
|
||||
WriteAuditLog(message string, userID *uuid.UUID, workspaceID *uuid.UUID)
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendEmail(to, subject, body string) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package users_models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PasswordResetCode struct {
|
||||
ID uuid.UUID `json:"id" gorm:"column:id"`
|
||||
UserID uuid.UUID `json:"userId" gorm:"column:user_id"`
|
||||
HashedCode string `json:"-" gorm:"column:hashed_code"`
|
||||
ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at"`
|
||||
IsUsed bool `json:"isUsed" gorm:"column:is_used"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func (PasswordResetCode) TableName() string {
|
||||
return "password_reset_codes"
|
||||
}
|
||||
|
||||
func (p *PasswordResetCode) IsValid() bool {
|
||||
return !p.IsUsed && time.Now().UTC().Before(p.ExpiresAt)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package users_repositories
|
||||
|
||||
var userRepository = &UserRepository{}
|
||||
var usersSettingsRepository = &UsersSettingsRepository{}
|
||||
var passwordResetRepository = &PasswordResetRepository{}
|
||||
|
||||
func GetUserRepository() *UserRepository {
|
||||
return userRepository
|
||||
@@ -10,3 +11,7 @@ func GetUserRepository() *UserRepository {
|
||||
func GetUsersSettingsRepository() *UsersSettingsRepository {
|
||||
return usersSettingsRepository
|
||||
}
|
||||
|
||||
func GetPasswordResetRepository() *PasswordResetRepository {
|
||||
return passwordResetRepository
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package users_repositories
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
users_models "databasus-backend/internal/features/users/models"
|
||||
"databasus-backend/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PasswordResetRepository struct{}
|
||||
|
||||
func (r *PasswordResetRepository) CreateResetCode(code *users_models.PasswordResetCode) error {
|
||||
if code.ID == uuid.Nil {
|
||||
code.ID = uuid.New()
|
||||
}
|
||||
|
||||
return storage.GetDb().Create(code).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) GetValidCodeByUserID(
|
||||
userID uuid.UUID,
|
||||
) (*users_models.PasswordResetCode, error) {
|
||||
var code users_models.PasswordResetCode
|
||||
err := storage.GetDb().
|
||||
Where("user_id = ? AND is_used = ? AND expires_at > ?", userID, false, time.Now().UTC()).
|
||||
Order("created_at DESC").
|
||||
First(&code).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &code, nil
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) MarkCodeAsUsed(codeID uuid.UUID) error {
|
||||
return storage.GetDb().Model(&users_models.PasswordResetCode{}).
|
||||
Where("id = ?", codeID).
|
||||
Update("is_used", true).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) DeleteExpiredCodes() error {
|
||||
return storage.GetDb().
|
||||
Where("expires_at < ?", time.Now().UTC()).
|
||||
Delete(&users_models.PasswordResetCode{}).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) CountRecentCodesByUserID(
|
||||
userID uuid.UUID,
|
||||
since time.Time,
|
||||
) (int64, error) {
|
||||
var count int64
|
||||
|
||||
err := storage.GetDb().Model(&users_models.PasswordResetCode{}).
|
||||
Where("user_id = ? AND created_at > ?", userID, since).
|
||||
Count(&count).Error
|
||||
|
||||
return count, err
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package users_services
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/features/email"
|
||||
"databasus-backend/internal/features/encryption/secrets"
|
||||
users_repositories "databasus-backend/internal/features/users/repositories"
|
||||
)
|
||||
@@ -10,6 +11,8 @@ var userService = &UserService{
|
||||
secrets.GetSecretKeyService(),
|
||||
settingsService,
|
||||
nil,
|
||||
email.GetEmailSMTPSender(),
|
||||
users_repositories.GetPasswordResetRepository(),
|
||||
}
|
||||
var settingsService = &SettingsService{
|
||||
users_repositories.GetUsersSettingsRepository(),
|
||||
|
||||
@@ -2,6 +2,7 @@ package users_services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -27,16 +28,22 @@ import (
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
userRepository *users_repositories.UserRepository
|
||||
secretKeyService *secrets.SecretKeyService
|
||||
settingsService *SettingsService
|
||||
auditLogWriter users_interfaces.AuditLogWriter
|
||||
userRepository *users_repositories.UserRepository
|
||||
secretKeyService *secrets.SecretKeyService
|
||||
settingsService *SettingsService
|
||||
auditLogWriter users_interfaces.AuditLogWriter
|
||||
emailSender users_interfaces.EmailSender
|
||||
passwordResetRepository *users_repositories.PasswordResetRepository
|
||||
}
|
||||
|
||||
func (s *UserService) SetAuditLogWriter(writer users_interfaces.AuditLogWriter) {
|
||||
s.auditLogWriter = writer
|
||||
}
|
||||
|
||||
func (s *UserService) SetEmailSender(sender users_interfaces.EmailSender) {
|
||||
s.emailSender = sender
|
||||
}
|
||||
|
||||
func (s *UserService) SignUp(request *users_dto.SignUpRequestDTO) error {
|
||||
existingUser, err := s.userRepository.GetUserByEmail(request.Email)
|
||||
if err != nil {
|
||||
@@ -798,3 +805,164 @@ func (s *UserService) fetchGitHubPrimaryEmail(
|
||||
|
||||
return "", errors.New("github account has no accessible email")
|
||||
}
|
||||
|
||||
func (s *UserService) SendResetPasswordCode(email string) error {
|
||||
user, err := s.userRepository.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
// Silently succeed for non-existent users to prevent enumeration attacks
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only active users can reset passwords
|
||||
if user.Status != users_enums.UserStatusActive {
|
||||
return errors.New("only active users can reset their password")
|
||||
}
|
||||
|
||||
// Check rate limiting - max 3 codes per hour
|
||||
oneHourAgo := time.Now().UTC().Add(-1 * time.Hour)
|
||||
recentCount, err := s.passwordResetRepository.CountRecentCodesByUserID(user.ID, oneHourAgo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check rate limit: %w", err)
|
||||
}
|
||||
|
||||
if recentCount >= 3 {
|
||||
return errors.New("too many password reset attempts, please try again later")
|
||||
}
|
||||
|
||||
// Generate 6-digit random code using crypto/rand for better randomness
|
||||
codeNum := make([]byte, 4)
|
||||
_, err = io.ReadFull(rand.Reader, codeNum)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate random code: %w", err)
|
||||
}
|
||||
|
||||
// Convert bytes to uint32 and modulo to get 6 digits
|
||||
randomInt := uint32(
|
||||
codeNum[0],
|
||||
)<<24 | uint32(
|
||||
codeNum[1],
|
||||
)<<16 | uint32(
|
||||
codeNum[2],
|
||||
)<<8 | uint32(
|
||||
codeNum[3],
|
||||
)
|
||||
code := fmt.Sprintf("%06d", randomInt%1000000)
|
||||
|
||||
// Hash the code
|
||||
hashedCode, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash code: %w", err)
|
||||
}
|
||||
|
||||
// Store in database with 1 hour expiration
|
||||
resetCode := &users_models.PasswordResetCode{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
HashedCode: string(hashedCode),
|
||||
ExpiresAt: time.Now().UTC().Add(1 * time.Hour),
|
||||
IsUsed: false,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := s.passwordResetRepository.CreateResetCode(resetCode); err != nil {
|
||||
return fmt.Errorf("failed to create reset code: %w", err)
|
||||
}
|
||||
|
||||
// Send email with code
|
||||
if s.emailSender != nil {
|
||||
subject := "Password Reset Code"
|
||||
body := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 20px;">
|
||||
<h2 style="color: #333333; margin-bottom: 20px;">Password Reset Request</h2>
|
||||
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
|
||||
You have requested to reset your password. Please use the following code to complete the password reset process:
|
||||
</p>
|
||||
<div style="background-color: #f8f9fa; border: 2px solid #e9ecef; border-radius: 8px; padding: 20px; text-align: center; margin: 30px 0;">
|
||||
<h1 style="color: #2c3e50; font-size: 36px; margin: 0; letter-spacing: 8px; font-family: monospace;">%s</h1>
|
||||
</div>
|
||||
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
|
||||
This code will expire in <strong>1 hour</strong>.
|
||||
</p>
|
||||
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
|
||||
If you did not request a password reset, please ignore this email. Your password will remain unchanged.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e9ecef; margin: 30px 0;">
|
||||
<p style="color: #999999; font-size: 12px; line-height: 1.6;">
|
||||
This is an automated message. Please do not reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, code)
|
||||
|
||||
if err := s.emailSender.SendEmail(user.Email, subject, body); err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
if s.auditLogWriter != nil {
|
||||
s.auditLogWriter.WriteAuditLog(
|
||||
fmt.Sprintf("Password reset code sent to: %s", user.Email),
|
||||
&user.ID,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) ResetPassword(email, code, newPassword string) error {
|
||||
user, err := s.userRepository.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return errors.New("user with this email does not exist")
|
||||
}
|
||||
|
||||
// Get valid reset code for user
|
||||
resetCode, err := s.passwordResetRepository.GetValidCodeByUserID(user.ID)
|
||||
if err != nil {
|
||||
return errors.New("invalid or expired reset code")
|
||||
}
|
||||
|
||||
// Verify code matches
|
||||
err = bcrypt.CompareHashAndPassword([]byte(resetCode.HashedCode), []byte(code))
|
||||
if err != nil {
|
||||
return errors.New("invalid reset code")
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
if err := s.passwordResetRepository.MarkCodeAsUsed(resetCode.ID); err != nil {
|
||||
return fmt.Errorf("failed to mark code as used: %w", err)
|
||||
}
|
||||
|
||||
// Update user password
|
||||
if err := s.ChangeUserPassword(user.ID, newPassword); err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
if s.auditLogWriter != nil {
|
||||
s.auditLogWriter.WriteAuditLog(
|
||||
"Password reset via email code",
|
||||
&user.ID,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
33
backend/internal/features/users/testing/mocks.go
Normal file
33
backend/internal/features/users/testing/mocks.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package users_testing
|
||||
|
||||
import "errors"
|
||||
|
||||
type MockEmailSender struct {
|
||||
SentEmails []EmailCall
|
||||
ShouldFail bool
|
||||
}
|
||||
|
||||
type EmailCall struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
|
||||
m.SentEmails = append(m.SentEmails, EmailCall{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
})
|
||||
if m.ShouldFail {
|
||||
return errors.New("mock email send failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewMockEmailSender() *MockEmailSender {
|
||||
return &MockEmailSender{
|
||||
SentEmails: []EmailCall{},
|
||||
ShouldFail: false,
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,8 @@ func Test_GetWorkspaceMembers_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -210,6 +212,8 @@ func Test_AddMemberToWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -270,6 +274,8 @@ func Test_AddMemberToWorkspace_WhenUserIsAlreadyMember_ReturnsBadRequest(t *test
|
||||
member := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
member,
|
||||
@@ -302,6 +308,7 @@ func Test_AddMemberToWorkspace_WithNonExistentUser_ReturnsInvited(t *testing.T)
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
request := workspaces_dto.AddMemberRequestDTO{
|
||||
Email: uuid.New().String() + "@example.com", // Non-existent user
|
||||
@@ -332,6 +339,8 @@ func Test_AddMemberToWorkspace_WhenWorkspaceAdminTriesToAddAdmin_ReturnsBadReque
|
||||
newMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
workspaceAdmin,
|
||||
@@ -368,6 +377,8 @@ func Test_AddMemberToWorkspace_WhenWorkspaceAdminTriesToAddWorkspaceAdmin_Return
|
||||
newMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
workspaceAdmin,
|
||||
@@ -450,6 +461,8 @@ func Test_AddWorkspaceAdmin_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -561,6 +574,7 @@ func Test_InviteMemberToWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -672,6 +686,7 @@ func Test_ChangeMemberRole_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
@@ -774,6 +789,7 @@ func Test_ChangeMemberRoleToAdmin_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
@@ -832,6 +848,7 @@ func Test_ChangeMemberRole_WhenChangingOwnRole_ReturnsBadRequest(t *testing.T) {
|
||||
)
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
request := workspaces_dto.ChangeMemberRoleRequestDTO{
|
||||
Role: users_enums.WorkspaceRoleMember,
|
||||
@@ -861,6 +878,7 @@ func Test_ChangeMemberRole_WhenChangingOwnerRole_ReturnsBadRequest(t *testing.T)
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
request := workspaces_dto.ChangeMemberRoleRequestDTO{
|
||||
Role: users_enums.WorkspaceRoleMember,
|
||||
@@ -941,6 +959,7 @@ func Test_RemoveMemberFromWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
@@ -995,6 +1014,7 @@ func Test_RemoveMemberFromWorkspace_WhenRemovingOwner_ReturnsBadRequest(t *testi
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
|
||||
Method: "DELETE",
|
||||
@@ -1061,6 +1081,7 @@ func Test_RemoveWorkspaceAdmin_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
@@ -1165,6 +1186,7 @@ func Test_TransferWorkspaceOwnership_PermissionsEnforced(t *testing.T) {
|
||||
owner,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
@@ -1225,6 +1247,7 @@ func Test_TransferWorkspaceOwnership_WhenNewOwnerIsNotMember_ReturnsBadRequest(t
|
||||
nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
request := workspaces_dto.TransferOwnershipRequestDTO{
|
||||
NewOwnerEmail: nonMember.Email,
|
||||
@@ -1250,6 +1273,8 @@ func Test_TransferWorkspaceOwnership_ThereIsOnlyOneOwner_OldOwnerBecomeAdmin(t *
|
||||
member := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
workspace, _ := workspaces_testing.CreateTestWorkspaceViaAPI("Test Workspace", owner, router)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspaceViaOwner(
|
||||
workspace,
|
||||
member,
|
||||
|
||||
@@ -91,6 +91,10 @@ func Test_CreateWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
assert.Equal(t, workspaceName, response.Name)
|
||||
assert.NotEqual(t, uuid.Nil, response.ID)
|
||||
assert.Equal(t, users_enums.WorkspaceRoleOwner, *response.UserRole)
|
||||
|
||||
// Cleanup created workspace
|
||||
workspace := &workspaces_models.Workspace{ID: response.ID}
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
} else {
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
@@ -160,11 +164,14 @@ func Test_GetUserWorkspaces_WhenUserHasWorkspaces_ReturnsWorkspacesList(t *testi
|
||||
user.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
|
||||
workspace2, _ := workspaces_testing.CreateTestWorkspaceWithToken(
|
||||
"Workspace 2",
|
||||
user.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
|
||||
var response workspaces_dto.ListWorkspacesResponseDTO
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
@@ -258,6 +265,7 @@ func Test_GetSingleWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -369,6 +377,7 @@ func Test_UpdateWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
var testUserToken string
|
||||
if tt.workspaceRole == users_enums.WorkspaceRoleOwner {
|
||||
@@ -472,6 +481,10 @@ func Test_DeleteWorkspace_PermissionsEnforced(t *testing.T) {
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
// Only cleanup if the test doesn't successfully delete the workspace
|
||||
if !tt.expectSuccess {
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
var testUserToken string
|
||||
if tt.isGlobalAdmin {
|
||||
@@ -526,6 +539,7 @@ func Test_GetWorkspaceAuditLogs_WhenUserIsWorkspaceAdmin_ReturnsAuditLogs(t *tes
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspace(
|
||||
workspace,
|
||||
@@ -565,11 +579,14 @@ func Test_GetWorkspaceAuditLogs_WithMultipleWorkspaces_ReturnsOnlyWorkspaceSpeci
|
||||
owner1.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace1, router)
|
||||
|
||||
workspace2, _ := workspaces_testing.CreateTestWorkspaceWithToken(
|
||||
workspaceName2,
|
||||
owner2.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
|
||||
updateWorkspace1 := workspaces_models.Workspace{
|
||||
Name: "Updated " + workspace1.Name,
|
||||
@@ -656,6 +673,7 @@ func Test_GetWorkspaceAuditLogs_WithDifferentUserRoles_EnforcesPermissionsCorrec
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
workspaces_testing.AddMemberToWorkspace(
|
||||
workspace,
|
||||
@@ -703,6 +721,7 @@ func Test_GetWorkspaceAuditLogs_WithoutAuthToken_ReturnsUnauthorized(t *testing.
|
||||
owner.Token,
|
||||
router,
|
||||
)
|
||||
defer workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
|
||||
test_utils.MakeGetRequest(t, router,
|
||||
"/api/v1/workspaces/"+workspace.ID.String()+"/audit-logs",
|
||||
|
||||
@@ -5,3 +5,7 @@ import "github.com/google/uuid"
|
||||
type WorkspaceDeletionListener interface {
|
||||
OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendEmail(to, subject, body string) error
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package workspaces_services
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/features/audit_logs"
|
||||
"databasus-backend/internal/features/email"
|
||||
users_services "databasus-backend/internal/features/users/services"
|
||||
workspaces_interfaces "databasus-backend/internal/features/workspaces/interfaces"
|
||||
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
|
||||
"databasus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var workspaceRepository = &workspaces_repositories.WorkspaceRepository{}
|
||||
@@ -26,6 +28,8 @@ var membershipService = &MembershipService{
|
||||
audit_logs.GetAuditLogService(),
|
||||
workspaceService,
|
||||
users_services.GetSettingsService(),
|
||||
email.GetEmailSMTPSender(),
|
||||
logger.GetLogger(),
|
||||
}
|
||||
|
||||
func GetWorkspaceService() *WorkspaceService {
|
||||
|
||||
@@ -2,7 +2,9 @@ package workspaces_services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"databasus-backend/internal/config"
|
||||
audit_logs "databasus-backend/internal/features/audit_logs"
|
||||
users_dto "databasus-backend/internal/features/users/dto"
|
||||
users_enums "databasus-backend/internal/features/users/enums"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
users_services "databasus-backend/internal/features/users/services"
|
||||
workspaces_dto "databasus-backend/internal/features/workspaces/dto"
|
||||
workspaces_errors "databasus-backend/internal/features/workspaces/errors"
|
||||
workspaces_interfaces "databasus-backend/internal/features/workspaces/interfaces"
|
||||
workspaces_models "databasus-backend/internal/features/workspaces/models"
|
||||
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
|
||||
|
||||
@@ -23,6 +26,8 @@ type MembershipService struct {
|
||||
auditLogService *audit_logs.AuditLogService
|
||||
workspaceService *WorkspaceService
|
||||
settingsService *users_services.SettingsService
|
||||
emailSender workspaces_interfaces.EmailSender
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *MembershipService) GetMembers(
|
||||
@@ -77,6 +82,12 @@ func (s *MembershipService) AddMember(
|
||||
return nil, workspaces_errors.ErrInsufficientPermissionsToInviteUsers
|
||||
}
|
||||
|
||||
// Get workspace details for email
|
||||
workspace, err := s.workspaceRepository.GetWorkspaceByID(workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get workspace: %w", err)
|
||||
}
|
||||
|
||||
inviteRequest := &users_dto.InviteUserRequestDTO{
|
||||
Email: request.Email,
|
||||
IntendedWorkspaceID: &workspaceID,
|
||||
@@ -88,6 +99,14 @@ func (s *MembershipService) AddMember(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Send invitation email
|
||||
subject := fmt.Sprintf("You've been invited to %s workspace", workspace.Name)
|
||||
body := s.buildInvitationEmailHTML(workspace.Name, addedBy.Name, string(request.Role))
|
||||
|
||||
if err := s.emailSender.SendEmail(request.Email, subject, body); err != nil {
|
||||
s.logger.Error("Failed to send invitation email", "email", request.Email, "error", err)
|
||||
}
|
||||
|
||||
membership := &workspaces_models.WorkspaceMembership{
|
||||
UserID: inviteResponse.ID,
|
||||
WorkspaceID: workspaceID,
|
||||
@@ -339,3 +358,48 @@ func (s *MembershipService) validateCanManageMembership(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MembershipService) buildInvitationEmailHTML(
|
||||
workspaceName, inviterName, role string,
|
||||
) string {
|
||||
env := config.GetEnv()
|
||||
signUpLink := ""
|
||||
if env.DatabasusURL != "" {
|
||||
signUpLink = fmt.Sprintf(`<p style="margin: 20px 0;">
|
||||
<a href="%s/sign-up" style="display: inline-block; padding: 12px 24px; background-color: #0d6efd; color: white; text-decoration: none; border-radius: 4px;">
|
||||
Sign up
|
||||
</a>
|
||||
</p>`, env.DatabasusURL)
|
||||
} else {
|
||||
signUpLink = `<p style="margin: 20px 0; color: #666;">
|
||||
Please visit your Databasus instance to sign up and access the workspace.
|
||||
</p>`
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background-color: #f8f9fa; border-radius: 8px; padding: 30px; margin: 20px 0;">
|
||||
<h1 style="color: #0d6efd; margin-top: 0;">Workspace Invitation</h1>
|
||||
|
||||
<p style="font-size: 16px; margin: 20px 0;">
|
||||
<strong>%s</strong> has invited you to join the <strong>%s</strong> workspace as a <strong>%s</strong>.
|
||||
</p>
|
||||
|
||||
%s
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #dee2e6; margin: 30px 0;">
|
||||
|
||||
<p style="font-size: 14px; color: #6c757d; margin: 0;">
|
||||
This is an automated message from Databasus. If you didn't expect this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, inviterName, workspaceName, role, signUpLink)
|
||||
}
|
||||
|
||||
33
backend/internal/features/workspaces/testing/mocks.go
Normal file
33
backend/internal/features/workspaces/testing/mocks.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package workspaces_testing
|
||||
|
||||
import "errors"
|
||||
|
||||
type MockEmailSender struct {
|
||||
SendEmailCalls []EmailCall
|
||||
ShouldFail bool
|
||||
}
|
||||
|
||||
type EmailCall struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
|
||||
m.SendEmailCalls = append(m.SendEmailCalls, EmailCall{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
})
|
||||
if m.ShouldFail {
|
||||
return errors.New("mock email send failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewMockEmailSender() *MockEmailSender {
|
||||
return &MockEmailSender{
|
||||
SendEmailCalls: []EmailCall{},
|
||||
ShouldFail: false,
|
||||
}
|
||||
}
|
||||
@@ -375,7 +375,13 @@ func RemoveTestWorkspace(workspace *workspaces_models.Workspace, router *gin.Eng
|
||||
membershipRepo := &workspaces_repositories.MembershipRepository{}
|
||||
workspaceMembers, err := membershipRepo.GetWorkspaceMembers(workspace.ID)
|
||||
if err != nil {
|
||||
panic("Failed to get workspace members: " + err.Error())
|
||||
// Workspace might already be deleted or doesn't exist, silently return
|
||||
return
|
||||
}
|
||||
|
||||
if len(workspaceMembers) == 0 {
|
||||
// No members found, workspace might have been deleted, silently return
|
||||
return
|
||||
}
|
||||
|
||||
var ownerToken string
|
||||
@@ -385,12 +391,16 @@ func RemoveTestWorkspace(workspace *workspaces_models.Workspace, router *gin.Eng
|
||||
|
||||
owner, err := userService.GetUserByID(m.UserID)
|
||||
if err != nil {
|
||||
panic("Failed to get owner user: " + err.Error())
|
||||
// Owner user not found, workspace might be in inconsistent state, try direct deletion
|
||||
_ = RemoveTestWorkspaceDirect(workspace.ID)
|
||||
return
|
||||
}
|
||||
|
||||
tokenResponse, err := userService.GenerateAccessToken(owner)
|
||||
if err != nil {
|
||||
panic("Failed to generate owner token: " + err.Error())
|
||||
// Cannot generate token, try direct deletion
|
||||
_ = RemoveTestWorkspaceDirect(workspace.ID)
|
||||
return
|
||||
}
|
||||
|
||||
ownerToken = tokenResponse.Token
|
||||
@@ -399,7 +409,9 @@ func RemoveTestWorkspace(workspace *workspaces_models.Workspace, router *gin.Eng
|
||||
}
|
||||
|
||||
if ownerToken == "" {
|
||||
panic("No workspace owner found")
|
||||
// No owner found, try direct deletion
|
||||
_ = RemoveTestWorkspaceDirect(workspace.ID)
|
||||
return
|
||||
}
|
||||
|
||||
DeleteWorkspace(workspace, ownerToken, router)
|
||||
|
||||
@@ -46,8 +46,8 @@ func LoadMainDb() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sqlDB.SetMaxOpenConns(20)
|
||||
sqlDB.SetMaxIdleConns(20)
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
|
||||
db = database
|
||||
|
||||
|
||||
@@ -1,48 +1,136 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
var (
|
||||
loggerInstance *slog.Logger
|
||||
once sync.Once
|
||||
loggerInstance *slog.Logger
|
||||
victoriaLogsWriter *VictoriaLogsWriter
|
||||
once sync.Once
|
||||
shutdownOnce sync.Once
|
||||
envLoadOnce sync.Once
|
||||
)
|
||||
|
||||
// GetLogger returns a singleton slog.Logger that logs to the console
|
||||
func GetLogger() *slog.Logger {
|
||||
once.Do(func() {
|
||||
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
// Create stdout handler
|
||||
stdoutHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey {
|
||||
a.Value = slog.StringValue(time.Now().Format("2006/01/02 15:04:05"))
|
||||
}
|
||||
|
||||
if a.Key == slog.MessageKey {
|
||||
// Format the message to match the desired output format
|
||||
return slog.Attr{
|
||||
Key: slog.MessageKey,
|
||||
Value: slog.StringValue(a.Value.String()),
|
||||
}
|
||||
}
|
||||
|
||||
// Remove level and other attributes to get clean output
|
||||
if a.Key == slog.LevelKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
})
|
||||
|
||||
loggerInstance = slog.New(handler)
|
||||
// Try to initialize VictoriaLogs writer if configured
|
||||
// Note: This will be called before config is fully loaded in some cases,
|
||||
// so we need to handle that gracefully
|
||||
victoriaLogsWriter = tryInitVictoriaLogs()
|
||||
|
||||
// Create multi-handler
|
||||
multiHandler := NewMultiHandler(stdoutHandler, victoriaLogsWriter)
|
||||
loggerInstance = slog.New(multiHandler)
|
||||
|
||||
loggerInstance.Info("Text structured logger initialized")
|
||||
if victoriaLogsWriter != nil {
|
||||
loggerInstance.Info("VictoriaLogs enabled")
|
||||
} else {
|
||||
loggerInstance.Info("VictoriaLogs disabled")
|
||||
}
|
||||
})
|
||||
|
||||
return loggerInstance
|
||||
}
|
||||
|
||||
// ShutdownVictoriaLogs gracefully shuts down the VictoriaLogs writer
|
||||
func ShutdownVictoriaLogs(timeout time.Duration) {
|
||||
shutdownOnce.Do(func() {
|
||||
if victoriaLogsWriter != nil {
|
||||
victoriaLogsWriter.Shutdown(timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func tryInitVictoriaLogs() *VictoriaLogsWriter {
|
||||
// Ensure .env is loaded before reading environment variables
|
||||
ensureEnvLoaded()
|
||||
|
||||
// Try to get config - this may fail early in startup
|
||||
url := getVictoriaLogsURL()
|
||||
username := getVictoriaLogsUsername()
|
||||
password := getVictoriaLogsPassword()
|
||||
|
||||
if url == "" {
|
||||
fmt.Println("VictoriaLogs URL is not set")
|
||||
return nil
|
||||
}
|
||||
|
||||
return NewVictoriaLogsWriter(url, username, password)
|
||||
}
|
||||
|
||||
func ensureEnvLoaded() {
|
||||
envLoadOnce.Do(func() {
|
||||
// Get current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: could not get current working directory: %v\n", err)
|
||||
cwd = "."
|
||||
}
|
||||
|
||||
// Find backend root by looking for go.mod
|
||||
backendRoot := cwd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(backendRoot, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
parent := filepath.Dir(backendRoot)
|
||||
if parent == backendRoot {
|
||||
break
|
||||
}
|
||||
|
||||
backendRoot = parent
|
||||
}
|
||||
|
||||
// Try to load .env from various locations
|
||||
envPaths := []string{
|
||||
filepath.Join(cwd, ".env"),
|
||||
filepath.Join(backendRoot, ".env"),
|
||||
}
|
||||
|
||||
for _, path := range envPaths {
|
||||
if err := godotenv.Load(path); err == nil {
|
||||
fmt.Printf("Logger: loaded .env from %s\n", path)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Logger: .env file not found, using existing environment variables")
|
||||
})
|
||||
}
|
||||
|
||||
func getVictoriaLogsURL() string {
|
||||
return os.Getenv("VICTORIA_LOGS_URL")
|
||||
}
|
||||
|
||||
func getVictoriaLogsUsername() string {
|
||||
return os.Getenv("VICTORIA_LOGS_USERNAME")
|
||||
}
|
||||
|
||||
func getVictoriaLogsPassword() string {
|
||||
return os.Getenv("VICTORIA_LOGS_PASSWORD")
|
||||
}
|
||||
|
||||
59
backend/internal/util/logger/multi_handler.go
Normal file
59
backend/internal/util/logger/multi_handler.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type MultiHandler struct {
|
||||
stdoutHandler slog.Handler
|
||||
victoriaLogsWriter *VictoriaLogsWriter
|
||||
}
|
||||
|
||||
func NewMultiHandler(
|
||||
stdoutHandler slog.Handler,
|
||||
victoriaLogsWriter *VictoriaLogsWriter,
|
||||
) *MultiHandler {
|
||||
return &MultiHandler{
|
||||
stdoutHandler: stdoutHandler,
|
||||
victoriaLogsWriter: victoriaLogsWriter,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MultiHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.stdoutHandler.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h *MultiHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
// Send to stdout handler
|
||||
if err := h.stdoutHandler.Handle(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send to VictoriaLogs if configured
|
||||
if h.victoriaLogsWriter != nil {
|
||||
attrs := make(map[string]interface{})
|
||||
record.Attrs(func(a slog.Attr) bool {
|
||||
attrs[a.Key] = a.Value.Any()
|
||||
return true
|
||||
})
|
||||
|
||||
h.victoriaLogsWriter.Write(record.Level.String(), record.Message, attrs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &MultiHandler{
|
||||
stdoutHandler: h.stdoutHandler.WithAttrs(attrs),
|
||||
victoriaLogsWriter: h.victoriaLogsWriter,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MultiHandler) WithGroup(name string) slog.Handler {
|
||||
return &MultiHandler{
|
||||
stdoutHandler: h.stdoutHandler.WithGroup(name),
|
||||
victoriaLogsWriter: h.victoriaLogsWriter,
|
||||
}
|
||||
}
|
||||
203
backend/internal/util/logger/victorialogs_writer.go
Normal file
203
backend/internal/util/logger/victorialogs_writer.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type logEntry struct {
|
||||
Time string `json:"_time"`
|
||||
Message string `json:"_msg"`
|
||||
Level string `json:"level"`
|
||||
Attrs map[string]any `json:",inline"`
|
||||
}
|
||||
|
||||
type VictoriaLogsWriter struct {
|
||||
url string
|
||||
username string
|
||||
password string
|
||||
httpClient *http.Client
|
||||
logChannel chan logEntry
|
||||
wg sync.WaitGroup
|
||||
once sync.Once
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewVictoriaLogsWriter(url, username, password string) *VictoriaLogsWriter {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
writer := &VictoriaLogsWriter{
|
||||
url: url,
|
||||
username: username,
|
||||
password: password,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
logChannel: make(chan logEntry, 1000),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
|
||||
}
|
||||
|
||||
// Start 3 worker goroutines
|
||||
for range 3 {
|
||||
writer.wg.Add(1)
|
||||
go writer.worker()
|
||||
}
|
||||
|
||||
return writer
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) Write(level, message string, attrs map[string]interface{}) {
|
||||
entry := logEntry{
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Message: message,
|
||||
Level: level,
|
||||
Attrs: attrs,
|
||||
}
|
||||
|
||||
select {
|
||||
case w.logChannel <- entry:
|
||||
// Successfully queued
|
||||
default:
|
||||
// Channel is full, drop log with warning
|
||||
w.logger.Warn("VictoriaLogs channel buffer full, dropping log entry")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) worker() {
|
||||
defer w.wg.Done()
|
||||
|
||||
batch := make([]logEntry, 0, 100)
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
w.flushBatch(batch)
|
||||
return
|
||||
|
||||
case entry, ok := <-w.logChannel:
|
||||
if !ok {
|
||||
w.flushBatch(batch)
|
||||
return
|
||||
}
|
||||
|
||||
batch = append(batch, entry)
|
||||
|
||||
// Send batch if it reaches 100 entries
|
||||
if len(batch) >= 100 {
|
||||
w.sendBatch(batch)
|
||||
batch = make([]logEntry, 0, 100)
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
if len(batch) > 0 {
|
||||
w.sendBatch(batch)
|
||||
batch = make([]logEntry, 0, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) sendBatch(entries []logEntry) {
|
||||
backoffs := []time.Duration{0, 5 * time.Second, 30 * time.Second, 1 * time.Minute}
|
||||
|
||||
for attempt := range 4 {
|
||||
if backoffs[attempt] > 0 {
|
||||
time.Sleep(backoffs[attempt])
|
||||
}
|
||||
|
||||
if err := w.sendHTTP(entries); err == nil {
|
||||
return
|
||||
} else if attempt == 3 {
|
||||
w.logger.Error("VictoriaLogs failed to send logs after 4 attempts",
|
||||
"error", err,
|
||||
"entries_count", len(entries))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) sendHTTP(entries []logEntry) error {
|
||||
// Build JSON Lines payload
|
||||
var buf bytes.Buffer
|
||||
encoder := json.NewEncoder(&buf)
|
||||
|
||||
for _, entry := range entries {
|
||||
if err := encoder.Encode(entry); err != nil {
|
||||
return fmt.Errorf("failed to encode log entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build request
|
||||
url := fmt.Sprintf("%s/insert/jsonline?_stream_fields=level&_msg_field=_msg", w.url)
|
||||
req, err := http.NewRequestWithContext(w.ctx, "POST", url, &buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/x-ndjson")
|
||||
|
||||
// Set Basic Auth (username:password)
|
||||
if w.password != "" {
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(w.username + ":" + w.password))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
}
|
||||
|
||||
// Send request
|
||||
resp, err := w.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// Check response
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("VictoriaLogs returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) flushBatch(batch []logEntry) {
|
||||
if len(batch) > 0 {
|
||||
w.sendBatch(batch)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *VictoriaLogsWriter) Shutdown(timeout time.Duration) {
|
||||
w.once.Do(func() {
|
||||
// Stop accepting new logs
|
||||
w.cancel()
|
||||
|
||||
// Wait for workers to finish with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
w.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
w.logger.Info("VictoriaLogs writer shutdown gracefully")
|
||||
case <-time.After(timeout):
|
||||
w.logger.Warn("VictoriaLogs writer shutdown timeout, some logs may be lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -47,3 +47,36 @@ func (p Period) ToDuration() time.Duration {
|
||||
panic("unknown period: " + string(p))
|
||||
}
|
||||
}
|
||||
|
||||
// CompareTo compares this period with another and returns:
|
||||
// -1 if p < other
|
||||
//
|
||||
// 0 if p == other
|
||||
// 1 if p > other
|
||||
//
|
||||
// FOREVER is treated as the longest period
|
||||
func (p Period) CompareTo(other Period) int {
|
||||
if p == other {
|
||||
return 0
|
||||
}
|
||||
|
||||
d1 := p.ToDuration()
|
||||
d2 := other.ToDuration()
|
||||
|
||||
// FOREVER has duration 0, but should be treated as longest period
|
||||
if p == PeriodForever {
|
||||
return 1
|
||||
}
|
||||
if other == PeriodForever {
|
||||
return -1
|
||||
}
|
||||
|
||||
if d1 < d2 {
|
||||
return -1
|
||||
}
|
||||
if d1 > d2 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE database_plans (
|
||||
database_id UUID PRIMARY KEY,
|
||||
max_backup_size_mb BIGINT NOT NULL,
|
||||
max_backups_total_size_mb BIGINT NOT NULL,
|
||||
max_storage_period TEXT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE database_plans
|
||||
ADD CONSTRAINT fk_database_plans_database_id
|
||||
FOREIGN KEY (database_id)
|
||||
REFERENCES databases (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_database_plans_database_id ON database_plans (database_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP INDEX IF EXISTS idx_database_plans_database_id;
|
||||
|
||||
ALTER TABLE database_plans DROP CONSTRAINT IF EXISTS fk_database_plans_database_id;
|
||||
|
||||
DROP TABLE IF EXISTS database_plans;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE storages
|
||||
ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE storages
|
||||
DROP COLUMN is_system;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,44 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE backups
|
||||
DROP CONSTRAINT fk_backups_storage_id;
|
||||
|
||||
ALTER TABLE backups
|
||||
ADD CONSTRAINT fk_backups_storage_id
|
||||
FOREIGN KEY (storage_id)
|
||||
REFERENCES storages (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE databases
|
||||
DROP CONSTRAINT fk_databases_workspace_id;
|
||||
|
||||
ALTER TABLE databases
|
||||
ADD CONSTRAINT fk_databases_workspace_id
|
||||
FOREIGN KEY (workspace_id)
|
||||
REFERENCES workspaces (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE backups
|
||||
DROP CONSTRAINT fk_backups_storage_id;
|
||||
|
||||
ALTER TABLE backups
|
||||
ADD CONSTRAINT fk_backups_storage_id
|
||||
FOREIGN KEY (storage_id)
|
||||
REFERENCES storages (id)
|
||||
ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE databases
|
||||
DROP CONSTRAINT fk_databases_workspace_id;
|
||||
|
||||
ALTER TABLE databases
|
||||
ADD CONSTRAINT fk_databases_workspace_id
|
||||
FOREIGN KEY (workspace_id)
|
||||
REFERENCES workspaces (id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,26 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE backup_configs
|
||||
DROP CONSTRAINT fk_backup_config_storage_id;
|
||||
|
||||
ALTER TABLE backup_configs
|
||||
ADD CONSTRAINT fk_backup_config_storage_id
|
||||
FOREIGN KEY (storage_id)
|
||||
REFERENCES storages (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE backup_configs
|
||||
DROP CONSTRAINT fk_backup_config_storage_id;
|
||||
|
||||
ALTER TABLE backup_configs
|
||||
ADD CONSTRAINT fk_backup_config_storage_id
|
||||
FOREIGN KEY (storage_id)
|
||||
REFERENCES storages (id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,31 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE password_reset_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
hashed_code TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE password_reset_codes
|
||||
ADD CONSTRAINT fk_password_reset_codes_user_id
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES users (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_password_reset_codes_user_id ON password_reset_codes (user_id);
|
||||
CREATE INDEX idx_password_reset_codes_expires_at ON password_reset_codes (expires_at);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP INDEX IF EXISTS idx_password_reset_codes_expires_at;
|
||||
DROP INDEX IF EXISTS idx_password_reset_codes_user_id;
|
||||
DROP TABLE IF EXISTS password_reset_codes;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE mongodb_databases ALTER COLUMN port DROP NOT NULL;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE mongodb_databases ADD COLUMN is_srv BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE mongodb_databases DROP COLUMN is_srv;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE mongodb_databases ALTER COLUMN port SET NOT NULL;
|
||||
-- +goose StatementEnd
|
||||
@@ -1 +1,5 @@
|
||||
MODE=development
|
||||
MODE=development
|
||||
VITE_GITHUB_CLIENT_ID=
|
||||
VITE_GOOGLE_CLIENT_ID=
|
||||
VITE_IS_EMAIL_CONFIGURED=false
|
||||
VITE_IS_CLOUD=false
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,6 +2,7 @@ interface RuntimeConfig {
|
||||
IS_CLOUD?: string;
|
||||
GITHUB_CLIENT_ID?: string;
|
||||
GOOGLE_CLIENT_ID?: string;
|
||||
IS_EMAIL_CONFIGURED?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -23,11 +24,8 @@ export function getApplicationServer() {
|
||||
}
|
||||
}
|
||||
|
||||
export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://databasus.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';
|
||||
|
||||
@@ -37,6 +35,10 @@ export const GITHUB_CLIENT_ID =
|
||||
export const GOOGLE_CLIENT_ID =
|
||||
window.__RUNTIME_CONFIG__?.GOOGLE_CLIENT_ID || import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
|
||||
|
||||
export const IS_EMAIL_CONFIGURED =
|
||||
window.__RUNTIME_CONFIG__?.IS_EMAIL_CONFIGURED === 'true' ||
|
||||
import.meta.env.VITE_IS_EMAIL_CONFIGURED === 'true';
|
||||
|
||||
export function getOAuthRedirectUri(): string {
|
||||
return `${window.location.origin}/auth/callback`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getApplicationServer } from '../../../constants';
|
||||
import RequestOptions from '../../../shared/api/RequestOptions';
|
||||
import { apiHelper } from '../../../shared/api/apiHelper';
|
||||
import type { DatabasePlan } from '../../plan';
|
||||
import type { BackupConfig } from '../model/BackupConfig';
|
||||
import type { TransferDatabaseRequest } from '../model/TransferDatabaseRequest';
|
||||
|
||||
@@ -54,4 +55,12 @@ export const backupConfigApi = {
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
async getDatabasePlan(databaseId: string) {
|
||||
return apiHelper.fetchGetJson<DatabasePlan>(
|
||||
`${getApplicationServer()}/api/v1/backup-configs/database/${databaseId}/plan`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,3 +6,4 @@ export type { BackupConfig } from './model/BackupConfig';
|
||||
export { BackupNotificationType } from './model/BackupNotificationType';
|
||||
export { BackupEncryption } from './model/BackupEncryption';
|
||||
export type { TransferDatabaseRequest } from './model/TransferDatabaseRequest';
|
||||
export type { DatabasePlan } from '../plan';
|
||||
|
||||
@@ -32,6 +32,7 @@ describe('MongodbConnectionStringParser', () => {
|
||||
expect(result.database).toBe('mydb');
|
||||
expect(result.authDatabase).toBe('admin');
|
||||
expect(result.useTls).toBe(false);
|
||||
expect(result.isSrv).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse connection string without database', () => {
|
||||
@@ -46,6 +47,7 @@ describe('MongodbConnectionStringParser', () => {
|
||||
expect(result.database).toBe('');
|
||||
expect(result.authDatabase).toBe('admin');
|
||||
expect(result.useTls).toBe(false);
|
||||
expect(result.isSrv).toBe(false);
|
||||
});
|
||||
|
||||
it('should default port to 27017 when not specified', () => {
|
||||
@@ -107,6 +109,7 @@ describe('MongodbConnectionStringParser', () => {
|
||||
expect(result.password).toBe('atlaspass');
|
||||
expect(result.database).toBe('mydb');
|
||||
expect(result.useTls).toBe(true); // SRV connections use TLS by default
|
||||
expect(result.isSrv).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse mongodb+srv:// without database', () => {
|
||||
@@ -119,6 +122,7 @@ describe('MongodbConnectionStringParser', () => {
|
||||
expect(result.host).toBe('cluster0.abc123.mongodb.net');
|
||||
expect(result.database).toBe('');
|
||||
expect(result.useTls).toBe(true);
|
||||
expect(result.isSrv).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -314,13 +318,15 @@ describe('MongodbConnectionStringParser', () => {
|
||||
expect(result.format).toBe('key-value');
|
||||
});
|
||||
|
||||
it('should return error for key-value format missing password', () => {
|
||||
const result = expectError(
|
||||
it('should allow missing password in key-value format (returns empty password)', () => {
|
||||
const result = expectSuccess(
|
||||
MongodbConnectionStringParser.parse('host=localhost database=mydb user=admin'),
|
||||
);
|
||||
|
||||
expect(result.error).toContain('Password');
|
||||
expect(result.format).toBe('key-value');
|
||||
expect(result.host).toBe('localhost');
|
||||
expect(result.username).toBe('admin');
|
||||
expect(result.password).toBe('');
|
||||
expect(result.database).toBe('mydb');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -351,12 +357,15 @@ describe('MongodbConnectionStringParser', () => {
|
||||
expect(result.error).toContain('Username');
|
||||
});
|
||||
|
||||
it('should return error for missing password in URI', () => {
|
||||
const result = expectError(
|
||||
it('should allow missing password in URI (returns empty password)', () => {
|
||||
const result = expectSuccess(
|
||||
MongodbConnectionStringParser.parse('mongodb://user@host:27017/db'),
|
||||
);
|
||||
|
||||
expect(result.error).toContain('Password');
|
||||
expect(result.username).toBe('user');
|
||||
expect(result.password).toBe('');
|
||||
expect(result.host).toBe('host');
|
||||
expect(result.database).toBe('db');
|
||||
});
|
||||
|
||||
it('should return error for mysql:// format (wrong database type)', () => {
|
||||
@@ -446,4 +455,67 @@ describe('MongodbConnectionStringParser', () => {
|
||||
expect(result.database).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password Placeholder Handling', () => {
|
||||
it('should treat <db_password> placeholder as empty password in URI format', () => {
|
||||
const result = expectSuccess(
|
||||
MongodbConnectionStringParser.parse('mongodb://user:<db_password>@host:27017/db'),
|
||||
);
|
||||
|
||||
expect(result.username).toBe('user');
|
||||
expect(result.password).toBe('');
|
||||
expect(result.host).toBe('host');
|
||||
expect(result.database).toBe('db');
|
||||
});
|
||||
|
||||
it('should treat <password> placeholder as empty password in URI format', () => {
|
||||
const result = expectSuccess(
|
||||
MongodbConnectionStringParser.parse('mongodb://user:<password>@host:27017/db'),
|
||||
);
|
||||
|
||||
expect(result.username).toBe('user');
|
||||
expect(result.password).toBe('');
|
||||
expect(result.host).toBe('host');
|
||||
expect(result.database).toBe('db');
|
||||
});
|
||||
|
||||
it('should treat <db_password> placeholder as empty password in SRV format', () => {
|
||||
const result = expectSuccess(
|
||||
MongodbConnectionStringParser.parse(
|
||||
'mongodb+srv://user:<db_password>@cluster0.mongodb.net/db',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.username).toBe('user');
|
||||
expect(result.password).toBe('');
|
||||
expect(result.host).toBe('cluster0.mongodb.net');
|
||||
expect(result.isSrv).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat <db_password> placeholder as empty password in key-value format', () => {
|
||||
const result = expectSuccess(
|
||||
MongodbConnectionStringParser.parse(
|
||||
'host=localhost database=mydb user=admin password=<db_password>',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.host).toBe('localhost');
|
||||
expect(result.username).toBe('admin');
|
||||
expect(result.password).toBe('');
|
||||
expect(result.database).toBe('mydb');
|
||||
});
|
||||
|
||||
it('should treat <password> placeholder as empty password in key-value format', () => {
|
||||
const result = expectSuccess(
|
||||
MongodbConnectionStringParser.parse(
|
||||
'host=localhost database=mydb user=admin password=<password>',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.host).toBe('localhost');
|
||||
expect(result.username).toBe('admin');
|
||||
expect(result.password).toBe('');
|
||||
expect(result.database).toBe('mydb');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ParseResult = {
|
||||
database: string;
|
||||
authDatabase: string;
|
||||
useTls: boolean;
|
||||
isSrv: boolean;
|
||||
};
|
||||
|
||||
export type ParseError = {
|
||||
@@ -63,7 +64,8 @@ export class MongodbConnectionStringParser {
|
||||
const host = url.hostname;
|
||||
const port = url.port ? parseInt(url.port, 10) : isSrv ? 27017 : 27017;
|
||||
const username = decodeURIComponent(url.username);
|
||||
const password = decodeURIComponent(url.password);
|
||||
const rawPassword = decodeURIComponent(url.password);
|
||||
const password = this.isPasswordPlaceholder(rawPassword) ? '' : rawPassword;
|
||||
const database = decodeURIComponent(url.pathname.slice(1));
|
||||
const authDatabase = this.getAuthSource(url.search) || 'admin';
|
||||
const useTls = isSrv ? true : this.checkTlsMode(url.search);
|
||||
@@ -76,10 +78,6 @@ export class MongodbConnectionStringParser {
|
||||
return { error: 'Username is missing from connection string' };
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return { error: 'Password is missing from connection string' };
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
@@ -88,6 +86,7 @@ export class MongodbConnectionStringParser {
|
||||
database: database || '',
|
||||
authDatabase,
|
||||
useTls,
|
||||
isSrv,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
@@ -114,7 +113,8 @@ export class MongodbConnectionStringParser {
|
||||
const port = params['port'];
|
||||
const database = params['database'] || params['dbname'] || params['db'];
|
||||
const username = params['user'] || params['username'];
|
||||
const password = params['password'];
|
||||
const rawPassword = params['password'];
|
||||
const password = this.isPasswordPlaceholder(rawPassword) ? '' : rawPassword || '';
|
||||
const authDatabase = params['authSource'] || params['authDatabase'] || 'admin';
|
||||
const tls = params['tls'] || params['ssl'];
|
||||
|
||||
@@ -132,13 +132,6 @@ export class MongodbConnectionStringParser {
|
||||
};
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return {
|
||||
error: 'Password is missing from connection string. Use password=yourpassword',
|
||||
format: 'key-value',
|
||||
};
|
||||
}
|
||||
|
||||
const useTls = this.isTlsEnabled(tls);
|
||||
|
||||
return {
|
||||
@@ -149,6 +142,7 @@ export class MongodbConnectionStringParser {
|
||||
database: database || '',
|
||||
authDatabase,
|
||||
useTls,
|
||||
isSrv: false,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
@@ -191,4 +185,11 @@ export class MongodbConnectionStringParser {
|
||||
const enabledValues = ['true', 'yes', '1'];
|
||||
return enabledValues.includes(lowercased);
|
||||
}
|
||||
|
||||
private static isPasswordPlaceholder(password: string | null | undefined): boolean {
|
||||
if (!password) return false;
|
||||
|
||||
const trimmed = password.trim();
|
||||
return trimmed === '<db_password>' || trimmed === '<password>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ export interface MongodbDatabase {
|
||||
database: string;
|
||||
authDatabase: string;
|
||||
isHttps: boolean;
|
||||
isSrv: boolean;
|
||||
cpuCount: number;
|
||||
}
|
||||
|
||||
1
frontend/src/entity/plan/index.ts
Normal file
1
frontend/src/entity/plan/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { DatabasePlan } from './model/DatabasePlan';
|
||||
8
frontend/src/entity/plan/model/DatabasePlan.ts
Normal file
8
frontend/src/entity/plan/model/DatabasePlan.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Period } from '../../databases/model/Period';
|
||||
|
||||
export interface DatabasePlan {
|
||||
databaseId: string;
|
||||
maxBackupSizeMb: number;
|
||||
maxBackupsTotalSizeMb: number;
|
||||
maxStoragePeriod: Period;
|
||||
}
|
||||
@@ -2,5 +2,4 @@ export interface GoogleDriveStorage {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
tokenJson?: string;
|
||||
useLocalRedirect?: boolean;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Storage {
|
||||
name: string;
|
||||
lastSaveError?: string;
|
||||
workspaceId: string;
|
||||
isSystem: boolean;
|
||||
|
||||
// specific storage types
|
||||
localStorage?: LocalStorage;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Storage } from './Storage';
|
||||
|
||||
export interface StorageOauthDto {
|
||||
redirectUrl: string;
|
||||
storage: Storage;
|
||||
authCode: string;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { InviteUserResponse } from '../model/InviteUserResponse';
|
||||
import type { IsAdminHasPasswordResponse } from '../model/IsAdminHasPasswordResponse';
|
||||
import type { OAuthCallbackRequest } from '../model/OAuthCallbackRequest';
|
||||
import type { OAuthCallbackResponse } from '../model/OAuthCallbackResponse';
|
||||
import type { ResetPasswordRequest } from '../model/ResetPasswordRequest';
|
||||
import type { SendResetPasswordCodeRequest } from '../model/SendResetPasswordCodeRequest';
|
||||
import type { SetAdminPasswordRequest } from '../model/SetAdminPasswordRequest';
|
||||
import type { SignInRequest } from '../model/SignInRequest';
|
||||
import type { SignInResponse } from '../model/SignInResponse';
|
||||
@@ -134,6 +136,24 @@ export const userApi = {
|
||||
});
|
||||
},
|
||||
|
||||
async sendResetPasswordCode(request: SendResetPasswordCodeRequest): Promise<{ message: string }> {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
requestOptions.setBody(JSON.stringify(request));
|
||||
return apiHelper.fetchPostJson(
|
||||
`${getApplicationServer()}/api/v1/users/send-reset-password-code`,
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
async resetPassword(request: ResetPasswordRequest): Promise<{ message: string }> {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
requestOptions.setBody(JSON.stringify(request));
|
||||
return apiHelper.fetchPostJson(
|
||||
`${getApplicationServer()}/api/v1/users/reset-password`,
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
isAuthorized: (): boolean => !!accessTokenHelper.getAccessToken(),
|
||||
|
||||
logout: () => {
|
||||
|
||||
@@ -18,5 +18,7 @@ export type { ListUsersRequest } from './model/ListUsersRequest';
|
||||
export type { ListUsersResponse } from './model/ListUsersResponse';
|
||||
export type { ChangeUserRoleRequest } from './model/ChangeUserRoleRequest';
|
||||
export type { UsersSettings } from './model/UsersSettings';
|
||||
export type { SendResetPasswordCodeRequest } from './model/SendResetPasswordCodeRequest';
|
||||
export type { ResetPasswordRequest } from './model/ResetPasswordRequest';
|
||||
export { UserRole } from './model/UserRole';
|
||||
export { WorkspaceRole } from './model/WorkspaceRole';
|
||||
|
||||
5
frontend/src/entity/users/model/ResetPasswordRequest.ts
Normal file
5
frontend/src/entity/users/model/ResetPasswordRequest.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ResetPasswordRequest {
|
||||
email: string;
|
||||
code: string;
|
||||
newPassword: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface SendResetPasswordCodeRequest {
|
||||
email: string;
|
||||
}
|
||||
@@ -15,12 +15,19 @@ import { CronExpressionParser } from 'cron-parser';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { type BackupConfig, BackupEncryption, backupConfigApi } from '../../../entity/backups';
|
||||
import { IS_CLOUD } from '../../../constants';
|
||||
import {
|
||||
type BackupConfig,
|
||||
BackupEncryption,
|
||||
type DatabasePlan,
|
||||
backupConfigApi,
|
||||
} from '../../../entity/backups';
|
||||
import { BackupNotificationType } from '../../../entity/backups/model/BackupNotificationType';
|
||||
import type { Database } from '../../../entity/databases';
|
||||
import { Period } from '../../../entity/databases/model/Period';
|
||||
import { type Interval, IntervalType } from '../../../entity/intervals';
|
||||
import { type Storage, getStorageLogoFromType, storageApi } from '../../../entity/storages';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import { getUserTimeFormat } from '../../../shared/time';
|
||||
import {
|
||||
getUserTimeFormat as getIs12Hour,
|
||||
@@ -33,6 +40,7 @@ import { ConfirmationComponent } from '../../../shared/ui';
|
||||
import { EditStorageComponent } from '../../storages/ui/edit/EditStorageComponent';
|
||||
|
||||
interface Props {
|
||||
user: UserProfile;
|
||||
database: Database;
|
||||
|
||||
isShowBackButton: boolean;
|
||||
@@ -57,6 +65,7 @@ const weekdayOptions = [
|
||||
];
|
||||
|
||||
export const EditBackupConfigComponent = ({
|
||||
user,
|
||||
database,
|
||||
|
||||
isShowBackButton,
|
||||
@@ -73,12 +82,14 @@ export const EditBackupConfigComponent = ({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [storages, setStorages] = useState<Storage[]>([]);
|
||||
const [isStoragesLoading, setIsStoragesLoading] = useState(false);
|
||||
const [isShowCreateStorage, setShowCreateStorage] = useState(false);
|
||||
const [storageSelectKey, setStorageSelectKey] = useState(0);
|
||||
|
||||
const [isShowWarn, setIsShowWarn] = useState(false);
|
||||
|
||||
const [databasePlan, setDatabasePlan] = useState<DatabasePlan>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const hasAdvancedValues =
|
||||
!!backupConfig?.isRetryIfFailed ||
|
||||
(backupConfig?.maxBackupSizeMb ?? 0) > 0 ||
|
||||
@@ -92,6 +103,65 @@ export const EditBackupConfigComponent = ({
|
||||
|
||||
const dateTimeFormat = useMemo(() => getUserTimeFormat(), []);
|
||||
|
||||
const createDefaultPlan = (databaseId: string, isCloud: boolean): DatabasePlan => {
|
||||
if (isCloud) {
|
||||
return {
|
||||
databaseId,
|
||||
maxBackupSizeMb: 100,
|
||||
maxBackupsTotalSizeMb: 4000,
|
||||
maxStoragePeriod: Period.WEEK,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
databaseId,
|
||||
maxBackupSizeMb: 0,
|
||||
maxBackupsTotalSizeMb: 0,
|
||||
maxStoragePeriod: Period.FOREVER,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isPeriodAllowed = (period: Period, maxPeriod: Period): boolean => {
|
||||
const periodOrder = [
|
||||
Period.DAY,
|
||||
Period.WEEK,
|
||||
Period.MONTH,
|
||||
Period.THREE_MONTH,
|
||||
Period.SIX_MONTH,
|
||||
Period.YEAR,
|
||||
Period.TWO_YEARS,
|
||||
Period.THREE_YEARS,
|
||||
Period.FOUR_YEARS,
|
||||
Period.FIVE_YEARS,
|
||||
Period.FOREVER,
|
||||
];
|
||||
const periodIndex = periodOrder.indexOf(period);
|
||||
const maxIndex = periodOrder.indexOf(maxPeriod);
|
||||
return periodIndex <= maxIndex;
|
||||
};
|
||||
|
||||
const availablePeriods = useMemo(() => {
|
||||
const allPeriods = [
|
||||
{ label: '1 day', value: Period.DAY },
|
||||
{ label: '1 week', value: Period.WEEK },
|
||||
{ label: '1 month', value: Period.MONTH },
|
||||
{ label: '3 months', value: Period.THREE_MONTH },
|
||||
{ label: '6 months', value: Period.SIX_MONTH },
|
||||
{ label: '1 year', value: Period.YEAR },
|
||||
{ label: '2 years', value: Period.TWO_YEARS },
|
||||
{ label: '3 years', value: Period.THREE_YEARS },
|
||||
{ label: '4 years', value: Period.FOUR_YEARS },
|
||||
{ label: '5 years', value: Period.FIVE_YEARS },
|
||||
{ label: 'Forever', value: Period.FOREVER },
|
||||
];
|
||||
|
||||
if (!databasePlan) {
|
||||
return allPeriods;
|
||||
}
|
||||
|
||||
return allPeriods.filter((p) => isPeriodAllowed(p.value, databasePlan.maxStoragePeriod));
|
||||
}, [databasePlan]);
|
||||
|
||||
const updateBackupConfig = (patch: Partial<BackupConfig>) => {
|
||||
setBackupConfig((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
setIsUnsaved(true);
|
||||
@@ -131,51 +201,70 @@ export const EditBackupConfigComponent = ({
|
||||
};
|
||||
|
||||
const loadStorages = async () => {
|
||||
setIsStoragesLoading(true);
|
||||
|
||||
try {
|
||||
const storages = await storageApi.getStorages(database.workspaceId);
|
||||
setStorages(storages);
|
||||
|
||||
if (IS_CLOUD) {
|
||||
const systemStorages = storages.filter((s) => s.isSystem);
|
||||
if (systemStorages.length > 0) {
|
||||
updateBackupConfig({ storage: systemStorages[0] });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
setIsStoragesLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (database.id) {
|
||||
backupConfigApi.getBackupConfigByDbID(database.id).then((res) => {
|
||||
setBackupConfig(res);
|
||||
setIsUnsaved(false);
|
||||
setIsSaving(false);
|
||||
});
|
||||
} else {
|
||||
setBackupConfig({
|
||||
databaseId: database.id,
|
||||
isBackupsEnabled: true,
|
||||
backupInterval: {
|
||||
id: undefined as unknown as string,
|
||||
interval: IntervalType.DAILY,
|
||||
timeOfDay: '00:00',
|
||||
},
|
||||
storage: undefined,
|
||||
storePeriod: Period.THREE_MONTH,
|
||||
sendNotificationsOn: [BackupNotificationType.BackupFailed],
|
||||
isRetryIfFailed: true,
|
||||
maxFailedTriesCount: 3,
|
||||
encryption: BackupEncryption.ENCRYPTED,
|
||||
const run = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
maxBackupSizeMb: 0,
|
||||
maxBackupsTotalSizeMb: 0,
|
||||
});
|
||||
}
|
||||
loadStorages();
|
||||
try {
|
||||
if (database.id) {
|
||||
const config = await backupConfigApi.getBackupConfigByDbID(database.id);
|
||||
setBackupConfig(config);
|
||||
setIsUnsaved(false);
|
||||
setIsSaving(false);
|
||||
|
||||
const plan = await backupConfigApi.getDatabasePlan(database.id);
|
||||
setDatabasePlan(plan);
|
||||
} else {
|
||||
const plan = createDefaultPlan('', IS_CLOUD);
|
||||
setDatabasePlan(plan);
|
||||
|
||||
setBackupConfig({
|
||||
databaseId: database.id,
|
||||
isBackupsEnabled: true,
|
||||
backupInterval: {
|
||||
id: undefined as unknown as string,
|
||||
interval: IntervalType.DAILY,
|
||||
timeOfDay: '00:00',
|
||||
},
|
||||
storage: undefined,
|
||||
storePeriod:
|
||||
plan.maxStoragePeriod === Period.FOREVER ? Period.THREE_MONTH : plan.maxStoragePeriod,
|
||||
sendNotificationsOn: [BackupNotificationType.BackupFailed],
|
||||
isRetryIfFailed: true,
|
||||
maxFailedTriesCount: 3,
|
||||
encryption: BackupEncryption.ENCRYPTED,
|
||||
maxBackupSizeMb: plan.maxBackupSizeMb,
|
||||
maxBackupsTotalSizeMb: plan.maxBackupsTotalSizeMb,
|
||||
});
|
||||
}
|
||||
|
||||
await loadStorages();
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
}, [database]);
|
||||
|
||||
if (!backupConfig) return <div />;
|
||||
|
||||
if (isStoragesLoading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mb-5 flex items-center">
|
||||
<Spin />
|
||||
@@ -183,6 +272,8 @@ export const EditBackupConfigComponent = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!backupConfig) return <div />;
|
||||
|
||||
const { backupInterval } = backupConfig;
|
||||
|
||||
// UTC → local conversions for display
|
||||
@@ -414,28 +505,30 @@ export const EditBackupConfigComponent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Encryption</div>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={backupConfig.encryption}
|
||||
onChange={(v) => updateBackupConfig({ encryption: v })}
|
||||
size="small"
|
||||
className="w-[200px]"
|
||||
options={[
|
||||
{ label: 'None', value: BackupEncryption.NONE },
|
||||
{ label: 'Encrypt backup files', value: BackupEncryption.ENCRYPTED },
|
||||
]}
|
||||
/>
|
||||
{!IS_CLOUD && (
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Encryption</div>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={backupConfig.encryption}
|
||||
onChange={(v) => updateBackupConfig({ encryption: v })}
|
||||
size="small"
|
||||
className="w-[200px]"
|
||||
options={[
|
||||
{ label: 'None', value: BackupEncryption.NONE },
|
||||
{ label: 'Encrypt backup files', value: BackupEncryption.ENCRYPTED },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Databasus or download them unencrypted via the 'Download' button."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Databasus or download them unencrypted via the 'Download' button."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Store period</div>
|
||||
@@ -445,19 +538,7 @@ export const EditBackupConfigComponent = ({
|
||||
onChange={(v) => updateBackupConfig({ storePeriod: v })}
|
||||
size="small"
|
||||
className="w-[200px]"
|
||||
options={[
|
||||
{ label: '1 day', value: Period.DAY },
|
||||
{ label: '1 week', value: Period.WEEK },
|
||||
{ label: '1 month', value: Period.MONTH },
|
||||
{ label: '3 months', value: Period.THREE_MONTH },
|
||||
{ label: '6 months', value: Period.SIX_MONTH },
|
||||
{ label: '1 year', value: Period.YEAR },
|
||||
{ label: '2 years', value: Period.TWO_YEARS },
|
||||
{ label: '3 years', value: Period.THREE_YEARS },
|
||||
{ label: '4 years', value: Period.FOUR_YEARS },
|
||||
{ label: '5 years', value: Period.FIVE_YEARS },
|
||||
{ label: 'Forever', value: Period.FOREVER },
|
||||
]}
|
||||
options={availablePeriods}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
@@ -559,7 +640,7 @@ export const EditBackupConfigComponent = ({
|
||||
value={backupConfig.maxFailedTriesCount}
|
||||
onChange={(value) => updateBackupConfig({ maxFailedTriesCount: value || 1 })}
|
||||
size="small"
|
||||
className="w-full max-w-[200px] grow"
|
||||
className="w-full max-w-[75px] grow"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
@@ -575,16 +656,16 @@ export const EditBackupConfigComponent = ({
|
||||
<div className="mt-5 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Max backup size limit</div>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
<Switch
|
||||
size="small"
|
||||
checked={backupConfig.maxBackupSizeMb > 0}
|
||||
onChange={(e) => {
|
||||
disabled={IS_CLOUD}
|
||||
onChange={(checked) => {
|
||||
updateBackupConfig({
|
||||
maxBackupSizeMb: e.target.checked ? backupConfig.maxBackupSizeMb || 1000 : 0,
|
||||
maxBackupSizeMb: checked ? backupConfig.maxBackupSizeMb || 1000 : 0,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Enable
|
||||
</Checkbox>
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
@@ -596,19 +677,33 @@ export const EditBackupConfigComponent = ({
|
||||
</div>
|
||||
|
||||
{backupConfig.maxBackupSizeMb > 0 && (
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Max backup size (MB)</div>
|
||||
<div className="mb-5 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Max file size (MB)</div>
|
||||
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={
|
||||
databasePlan?.maxBackupSizeMb && databasePlan.maxBackupSizeMb > 0
|
||||
? databasePlan.maxBackupSizeMb
|
||||
: undefined
|
||||
}
|
||||
value={backupConfig.maxBackupSizeMb}
|
||||
onChange={(value) => updateBackupConfig({ maxBackupSizeMb: value || 1 })}
|
||||
onChange={(value) => {
|
||||
const newValue = value || 1;
|
||||
if (databasePlan?.maxBackupSizeMb && databasePlan.maxBackupSizeMb > 0) {
|
||||
updateBackupConfig({
|
||||
maxBackupSizeMb: Math.min(newValue, databasePlan.maxBackupSizeMb),
|
||||
});
|
||||
} else {
|
||||
updateBackupConfig({ maxBackupSizeMb: newValue });
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[100px] grow"
|
||||
className="w-full max-w-[75px] grow"
|
||||
/>
|
||||
|
||||
<div className="ml-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{(backupConfig.maxBackupSizeMb / 1024).toFixed(2)} GB
|
||||
~{((backupConfig.maxBackupSizeMb / 1024) * 15).toFixed(2)} GB DB size
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -616,22 +711,22 @@ export const EditBackupConfigComponent = ({
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Limit total backups size</div>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
<Switch
|
||||
size="small"
|
||||
checked={backupConfig.maxBackupsTotalSizeMb > 0}
|
||||
onChange={(e) => {
|
||||
disabled={IS_CLOUD}
|
||||
onChange={(checked) => {
|
||||
updateBackupConfig({
|
||||
maxBackupsTotalSizeMb: e.target.checked
|
||||
maxBackupsTotalSizeMb: checked
|
||||
? backupConfig.maxBackupsTotalSizeMb || 1_000_000
|
||||
: 0,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Enable
|
||||
</Checkbox>
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Limits the total size of all backups in storage. Once this limit is exceeded, the oldest backups are automatically removed until the total size is within the limit again."
|
||||
title="Limits the total size of all backups in storage (like S3, local, etc.). Once this limit is exceeded, the oldest backups are automatically removed until the total size is within the limit again."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
@@ -640,17 +735,35 @@ export const EditBackupConfigComponent = ({
|
||||
|
||||
{backupConfig.maxBackupsTotalSizeMb > 0 && (
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Max total size (MB)</div>
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Backups files size (MB)</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={
|
||||
databasePlan?.maxBackupsTotalSizeMb && databasePlan.maxBackupsTotalSizeMb > 0
|
||||
? databasePlan.maxBackupsTotalSizeMb
|
||||
: undefined
|
||||
}
|
||||
value={backupConfig.maxBackupsTotalSizeMb}
|
||||
onChange={(value) => updateBackupConfig({ maxBackupsTotalSizeMb: value || 1 })}
|
||||
onChange={(value) => {
|
||||
const newValue = value || 1;
|
||||
if (
|
||||
databasePlan?.maxBackupsTotalSizeMb &&
|
||||
databasePlan.maxBackupsTotalSizeMb > 0
|
||||
) {
|
||||
updateBackupConfig({
|
||||
maxBackupsTotalSizeMb: Math.min(newValue, databasePlan.maxBackupsTotalSizeMb),
|
||||
});
|
||||
} else {
|
||||
updateBackupConfig({ maxBackupsTotalSizeMb: newValue });
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[100px] grow"
|
||||
className="w-full max-w-[75px] grow"
|
||||
/>
|
||||
|
||||
<div className="ml-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{(backupConfig.maxBackupsTotalSizeMb / 1024).toFixed(2)} GB
|
||||
{(backupConfig.maxBackupsTotalSizeMb / 1024).toFixed(2)} GB (~
|
||||
{backupConfig.maxBackupsTotalSizeMb / backupConfig.maxBackupSizeMb} backups)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -697,6 +810,7 @@ export const EditBackupConfigComponent = ({
|
||||
</div>
|
||||
|
||||
<EditStorageComponent
|
||||
user={user}
|
||||
workspaceId={database.workspaceId}
|
||||
isShowName
|
||||
isShowClose={false}
|
||||
|
||||
@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IS_CLOUD } from '../../../constants';
|
||||
import { type BackupConfig, BackupEncryption, backupConfigApi } from '../../../entity/backups';
|
||||
import { BackupNotificationType } from '../../../entity/backups/model/BackupNotificationType';
|
||||
import type { Database } from '../../../entity/databases';
|
||||
@@ -210,17 +211,21 @@ export const ShowBackupConfigComponent = ({ database }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Encryption</div>
|
||||
<div>{backupConfig.encryption === BackupEncryption.ENCRYPTED ? 'Enabled' : 'None'}</div>
|
||||
{!IS_CLOUD && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Encryption</div>
|
||||
<div>
|
||||
{backupConfig.encryption === BackupEncryption.ENCRYPTED ? 'Enabled' : 'None'}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Databasus or download them unencrypted via the 'Download' button."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Databasus or download them unencrypted via the 'Download' button."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Notifications</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type PostgresqlDatabase,
|
||||
databaseApi,
|
||||
} from '../../../entity/databases';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import { EditBackupConfigComponent } from '../../backups';
|
||||
import { CreateReadOnlyComponent } from './edit/CreateReadOnlyComponent';
|
||||
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
|
||||
@@ -18,8 +19,8 @@ import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComp
|
||||
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
|
||||
|
||||
interface Props {
|
||||
user: UserProfile;
|
||||
workspaceId: string;
|
||||
|
||||
onCreated: (databaseId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -62,7 +63,7 @@ const initializeDatabaseTypeData = (db: Database): Database => {
|
||||
}
|
||||
};
|
||||
|
||||
export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Props) => {
|
||||
export const CreateDatabaseComponent = ({ user, workspaceId, onCreated, onClose }: Props) => {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [backupConfig, setBackupConfig] = useState<BackupConfig | undefined>();
|
||||
const [database, setDatabase] = useState<Database>(createInitialDatabase(workspaceId));
|
||||
@@ -149,6 +150,7 @@ export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Pro
|
||||
if (step === 'backup-config') {
|
||||
return (
|
||||
<EditBackupConfigComponent
|
||||
user={user}
|
||||
database={database}
|
||||
isShowCancelButton={false}
|
||||
onCancel={() => onClose()}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRef, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { type Database, databaseApi } from '../../../entity/databases';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import { BackupsComponent } from '../../backups';
|
||||
import { HealthckeckAttemptsComponent } from '../../healthcheck';
|
||||
import { DatabaseConfigComponent } from './DatabaseConfigComponent';
|
||||
@@ -10,6 +11,7 @@ import { DatabaseConfigComponent } from './DatabaseConfigComponent';
|
||||
interface Props {
|
||||
contentHeight: number;
|
||||
databaseId: string;
|
||||
user: UserProfile;
|
||||
onDatabaseChanged: (database: Database) => void;
|
||||
onDatabaseDeleted: () => void;
|
||||
isCanManageDBs: boolean;
|
||||
@@ -18,6 +20,7 @@ interface Props {
|
||||
export const DatabaseComponent = ({
|
||||
contentHeight,
|
||||
databaseId,
|
||||
user,
|
||||
onDatabaseChanged,
|
||||
onDatabaseDeleted,
|
||||
isCanManageDBs,
|
||||
@@ -68,6 +71,7 @@ export const DatabaseComponent = ({
|
||||
{currentTab === 'config' && (
|
||||
<DatabaseConfigComponent
|
||||
database={database}
|
||||
user={user}
|
||||
setDatabase={setDatabase}
|
||||
onDatabaseChanged={onDatabaseChanged}
|
||||
onDatabaseDeleted={onDatabaseDeleted}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { backupConfigApi } from '../../../entity/backups';
|
||||
import { type Database, databaseApi } from '../../../entity/databases';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import { ToastHelper } from '../../../shared/toast';
|
||||
import { ConfirmationComponent } from '../../../shared/ui';
|
||||
import { EditBackupConfigComponent, ShowBackupConfigComponent } from '../../backups';
|
||||
@@ -22,6 +23,7 @@ import { ShowDatabaseSpecificDataComponent } from './show/ShowDatabaseSpecificDa
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
user: UserProfile;
|
||||
setDatabase: (database?: Database | undefined) => void;
|
||||
onDatabaseChanged: (database: Database) => void;
|
||||
onDatabaseDeleted: () => void;
|
||||
@@ -33,6 +35,7 @@ interface Props {
|
||||
|
||||
export const DatabaseConfigComponent = ({
|
||||
database,
|
||||
user,
|
||||
setDatabase,
|
||||
onDatabaseChanged,
|
||||
onDatabaseDeleted,
|
||||
@@ -311,6 +314,7 @@ export const DatabaseConfigComponent = ({
|
||||
{isEditBackupConfig ? (
|
||||
<EditBackupConfigComponent
|
||||
database={database}
|
||||
user={user}
|
||||
isShowCancelButton
|
||||
onCancel={() => {
|
||||
setIsEditBackupConfig(false);
|
||||
@@ -464,6 +468,7 @@ export const DatabaseConfigComponent = ({
|
||||
{isShowTransferDialog && (
|
||||
<DatabaseTransferDialogComponent
|
||||
database={database}
|
||||
user={user}
|
||||
currentStorageId={currentStorageId}
|
||||
onClose={() => setIsShowTransferDialog(false)}
|
||||
onTransferred={() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { type Database, databaseApi } from '../../../entity/databases';
|
||||
import type { Notifier } from '../../../entity/notifiers';
|
||||
import { notifierApi } from '../../../entity/notifiers';
|
||||
import { type Storage, getStorageLogoFromType, storageApi } from '../../../entity/storages';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import { type WorkspaceResponse, workspaceApi } from '../../../entity/workspaces';
|
||||
import { ToastHelper } from '../../../shared/toast';
|
||||
import { EditNotifierComponent } from '../../notifiers/ui/edit/EditNotifierComponent';
|
||||
@@ -15,6 +16,7 @@ import { EditStorageComponent } from '../../storages/ui/edit/EditStorageComponen
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
user: UserProfile;
|
||||
currentStorageId?: string;
|
||||
onClose: () => void;
|
||||
onTransferred: () => void;
|
||||
@@ -28,6 +30,7 @@ interface NotifierUsageInfo {
|
||||
|
||||
export const DatabaseTransferDialogComponent = ({
|
||||
database,
|
||||
user,
|
||||
currentStorageId,
|
||||
onClose,
|
||||
onTransferred,
|
||||
@@ -419,6 +422,7 @@ export const DatabaseTransferDialogComponent = ({
|
||||
|
||||
<EditStorageComponent
|
||||
workspaceId={selectedWorkspaceId}
|
||||
user={user}
|
||||
isShowName
|
||||
isShowClose={false}
|
||||
onClose={() => setIsShowCreateStorage(false)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { databaseApi } from '../../../entity/databases';
|
||||
import type { Database } from '../../../entity/databases';
|
||||
import type { UserProfile } from '../../../entity/users';
|
||||
import type { WorkspaceResponse } from '../../../entity/workspaces';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { CreateDatabaseComponent } from './CreateDatabaseComponent';
|
||||
@@ -12,12 +13,13 @@ import { DatabaseComponent } from './DatabaseComponent';
|
||||
interface Props {
|
||||
contentHeight: number;
|
||||
workspace: WorkspaceResponse;
|
||||
user: UserProfile;
|
||||
isCanManageDBs: boolean;
|
||||
}
|
||||
|
||||
const SELECTED_DATABASE_STORAGE_KEY = 'selectedDatabaseId';
|
||||
|
||||
export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }: Props) => {
|
||||
export const DatabasesComponent = ({ contentHeight, workspace, user, isCanManageDBs }: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [databases, setDatabases] = useState<Database[]>([]);
|
||||
@@ -157,6 +159,7 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
|
||||
<DatabaseComponent
|
||||
contentHeight={isMobile ? contentHeight - 50 : contentHeight}
|
||||
databaseId={selectedDatabaseId}
|
||||
user={user}
|
||||
onDatabaseChanged={() => {
|
||||
loadDatabases();
|
||||
}}
|
||||
@@ -185,6 +188,7 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
|
||||
<div className="mt-5" />
|
||||
|
||||
<CreateDatabaseComponent
|
||||
user={user}
|
||||
workspaceId={workspace.id}
|
||||
onCreated={(databaseId) => {
|
||||
loadDatabases(false, databaseId);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user