Merge pull request #349 from databasus/develop

Develop
This commit is contained in:
Rostislav Dugin
2026-02-09 16:42:00 +03:00
committed by GitHub
11 changed files with 405 additions and 56 deletions

View File

@@ -1366,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,

View File

@@ -1164,12 +1164,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,
}
}

View File

@@ -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,
)

View File

@@ -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")
}

View File

@@ -71,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,
}
}

View File

@@ -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,
},
}

View File

@@ -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

View File

@@ -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');
});
});
});

View File

@@ -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>';
}
}

View File

@@ -10,5 +10,6 @@ export interface MongodbDatabase {
database: string;
authDatabase: string;
isHttps: boolean;
isSrv: boolean;
cpuCount: number;
}

View File

@@ -46,7 +46,7 @@ export const EditMongoDbSpecificDataComponent = ({
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [isConnectionFailed, setIsConnectionFailed] = useState(false);
const hasAdvancedValues = !!database.mongodb?.authDatabase;
const hasAdvancedValues = !!database.mongodb?.authDatabase || !!database.mongodb?.isSrv;
const [isShowAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
const parseFromClipboard = async () => {
@@ -75,17 +75,29 @@ export const EditMongoDbSpecificDataComponent = ({
host: result.host,
port: result.port,
username: result.username,
password: result.password,
password: result.password || '',
database: result.database,
authDatabase: result.authDatabase,
isHttps: result.useTls,
isSrv: result.isSrv,
cpuCount: 1,
},
};
if (result.isSrv) {
setShowAdvanced(true);
}
setEditingDatabase(updatedDatabase);
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
if (!result.password) {
message.warning(
'Connection string parsed successfully. Please enter the password manually.',
);
} else {
message.success('Connection string parsed successfully');
}
} catch {
message.error('Failed to read clipboard. Please check browser permissions.');
}
@@ -156,9 +168,11 @@ export const EditMongoDbSpecificDataComponent = ({
if (!editingDatabase) return null;
const isSrvConnection = editingDatabase.mongodb?.isSrv || false;
let isAllFieldsFilled = true;
if (!editingDatabase.mongodb?.host) isAllFieldsFilled = false;
if (!editingDatabase.mongodb?.port) isAllFieldsFilled = false;
if (!isSrvConnection && !editingDatabase.mongodb?.port) isAllFieldsFilled = false;
if (!editingDatabase.mongodb?.username) isAllFieldsFilled = false;
if (!editingDatabase.id && !editingDatabase.mongodb?.password) isAllFieldsFilled = false;
if (!editingDatabase.mongodb?.database) isAllFieldsFilled = false;
@@ -220,25 +234,27 @@ export const EditMongoDbSpecificDataComponent = ({
</div>
)}
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Port</div>
<InputNumber
type="number"
value={editingDatabase.mongodb?.port}
onChange={(e) => {
if (!editingDatabase.mongodb || e === null) return;
{!isSrvConnection && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Port</div>
<InputNumber
type="number"
value={editingDatabase.mongodb?.port}
onChange={(e) => {
if (!editingDatabase.mongodb || e === null) return;
setEditingDatabase({
...editingDatabase,
mongodb: { ...editingDatabase.mongodb, port: e },
});
setIsConnectionTested(false);
}}
size="small"
className="max-w-[200px] grow"
placeholder="27017"
/>
</div>
setEditingDatabase({
...editingDatabase,
mongodb: { ...editingDatabase.mongodb, port: e },
});
setIsConnectionTested(false);
}}
size="small"
className="max-w-[200px] grow"
placeholder="27017"
/>
</div>
)}
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Username</div>
@@ -366,6 +382,31 @@ export const EditMongoDbSpecificDataComponent = ({
{isShowAdvanced && (
<>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Use SRV connection</div>
<div className="flex items-center">
<Switch
checked={editingDatabase.mongodb?.isSrv || false}
onChange={(checked) => {
if (!editingDatabase.mongodb) return;
setEditingDatabase({
...editingDatabase,
mongodb: { ...editingDatabase.mongodb, isSrv: checked },
});
setIsConnectionTested(false);
}}
size="small"
/>
<Tooltip
className="cursor-pointer"
title="Enable for MongoDB Atlas SRV connections (mongodb+srv://). Port is not required for SRV connections."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Auth database</div>
<Input