Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/config/toml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ Sources define database connections. Each source represents a database that DBHu
```
</Tab>
</Tabs>

<Note>
**Mixing `dsn` with individual fields.** A `dsn` already encodes the connection identity (type, host, port, database, user, password). Fields that can also live in the DSN query string — `sslmode`, `sslrootcert`, `instanceName`, `authentication`, `domain` — may be set alongside a `dsn` and are applied when the DSN omits them.

If such a field is set to a value that **contradicts** the DSN (e.g. `dsn = "...?sslmode=disable"` together with `sslmode = "require"`, or a `host`/`user`/`database` that differs from the DSN), DBHub rejects the configuration at startup rather than silently ignoring the field. Set each value in only one place, or make the two values match.
</Note>
</ParamField>

### connection_timeout
Expand Down
324 changes: 314 additions & 10 deletions src/config/__tests__/toml-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,29 +116,57 @@ dsn = "sqlite:///path/to/database.db"
expect(result?.sources[0].user).toBeUndefined();
});

it('should not override explicit connection params with DSN values', () => {
it('should reject identity fields that conflict with the DSN', () => {
// A DSN already encodes the connection identity; setting a field to a
// different value is silently ignored at connection time, so it must error.
const tomlContent = `
[[sources]]
id = "explicit_override"
dsn = "postgres://dsn_user:pass@dsn_host:5432/dsn_db"
type = "postgres"
host = "explicit_host"
port = 9999
database = "explicit_db"
user = "explicit_user"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("conflicting host");
});

it('should accept a host field that differs only in case from the DSN', () => {
const tomlContent = `
[[sources]]
id = "case_host"
dsn = "postgres://user:pass@DB.EXAMPLE.COM:5432/db"
host = "db.example.com"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

const result = loadTomlConfig();

expect(result?.sources[0].id).toBe('case_host');
});

it('should accept identity fields that match the DSN', () => {
const tomlContent = `
[[sources]]
id = "redundant"
dsn = "postgres://dsn_user:pass@dsn_host:5432/dsn_db"
type = "postgres"
host = "dsn_host"
port = 5432
database = "dsn_db"
user = "dsn_user"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

const result = loadTomlConfig();

// Explicit values should be preserved, not overwritten by DSN
expect(result?.sources[0]).toMatchObject({
id: 'explicit_override',
id: 'redundant',
type: 'postgres',
host: 'explicit_host',
port: 9999,
database: 'explicit_db',
user: 'explicit_user',
host: 'dsn_host',
port: 5432,
database: 'dsn_db',
user: 'dsn_user',
});
});

Expand Down Expand Up @@ -483,6 +511,145 @@ sslmode = "invalid"
expect(() => loadTomlConfig()).toThrow("invalid sslmode 'invalid'");
});

it('should throw error when DSN sslmode conflicts with sslmode field', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://user:pass@localhost:5432/db?sslmode=disable"
sslmode = "require"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("conflicting sslmode");
});

it('should accept matching DSN sslmode and sslmode field', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://user:pass@localhost:5432/db?sslmode=require"
sslmode = "require"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

const result = loadTomlConfig();

expect(result).toBeTruthy();
expect(result?.sources[0].sslmode).toBe('require');
});

it('should populate sslmode field from DSN query parameter', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://user:pass@localhost:5432/db?sslmode=require"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

const result = loadTomlConfig();

expect(result?.sources[0].sslmode).toBe('require');
});

it('should treat an empty DSN sslmode (?sslmode=) as present and conflicting', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://user:pass@localhost:5432/db?sslmode="
sslmode = "require"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("conflicting sslmode");
});

it('should throw error when DSN user conflicts with user field', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://dsn_user:pass@localhost:5432/db"
user = "other_user"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("conflicting user");
});

it('should throw error when DSN database conflicts with database field', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://user:pass@localhost:5432/dsn_db"
database = "other_db"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("conflicting database");
});

it('should throw error when password field conflicts with DSN password', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://user:dsn_pass@localhost:5432/db"
password = "other_pass"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("password' field that conflicts");
// The error must not echo either password value
expect(() => loadTomlConfig()).not.toThrow(/dsn_pass|other_pass/);
});

it('should report a clear error when password field is set but DSN has no password', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "postgres://user@localhost:5432/db"
password = "field_pass"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("the DSN has no password");
expect(() => loadTomlConfig()).not.toThrow(/field_pass/);
});

it('should throw error when type = "sqlite" conflicts with a non-SQLite DSN', () => {
const tomlContent = `
[[sources]]
id = "test_db"
type = "sqlite"
dsn = "postgres://user:pass@localhost:5432/db"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("conflicting type");
});

it('should throw error when type = "postgres" conflicts with a SQLite DSN', () => {
const tomlContent = `
[[sources]]
id = "test_db"
type = "postgres"
dsn = "sqlite:///path/to/db.sqlite"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("conflicting type");
});

it('should throw error when DSN instanceName conflicts with instanceName field', () => {
const tomlContent = `
[[sources]]
id = "test_db"
dsn = "sqlserver://sa:pass@localhost:1433/db?instanceName=ENV1"
instanceName = "ENV2"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("conflicting instanceName");
});

it('should throw error when sslmode is specified for SQLite', () => {
const tomlContent = `
[[sources]]
Expand Down Expand Up @@ -1134,6 +1301,143 @@ dsn = "mysql://user:pass@localhost:3306/testdb"
expect(dsn).toBe('postgres://user:pass@localhost:5432/db');
});

it('should merge sslmode field into a DSN that lacks it', () => {
const source: SourceConfig = {
id: 'test',
type: 'postgres',
dsn: 'postgres://user:pass@localhost:5432/db',
sslmode: 'require',
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('postgres://user:pass@localhost:5432/db?sslmode=require');
Comment thread
tianzhou marked this conversation as resolved.
});

it('should append sslmode with & when DSN already has query params', () => {
const source: SourceConfig = {
id: 'test',
type: 'sqlserver',
dsn: 'sqlserver://user:pass@localhost:1433/db?instanceName=ENV1',
sslmode: 'require',
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('sqlserver://user:pass@localhost:1433/db?instanceName=ENV1&sslmode=require');
});

it('should not duplicate sslmode when DSN already specifies it', () => {
const source: SourceConfig = {
id: 'test',
type: 'postgres',
dsn: 'postgres://user:pass@localhost:5432/db?sslmode=require',
sslmode: 'require',
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('postgres://user:pass@localhost:5432/db?sslmode=require');
});

it('should merge instanceName field into a SQL Server DSN that lacks it', () => {
const source: SourceConfig = {
id: 'test',
type: 'sqlserver',
dsn: 'sqlserver://sa:pass@localhost:1433/db',
instanceName: 'ENV1',
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('sqlserver://sa:pass@localhost:1433/db?instanceName=ENV1');
});

it('should merge authentication and domain fields into a SQL Server DSN', () => {
const source: SourceConfig = {
id: 'test',
type: 'sqlserver',
dsn: 'sqlserver://user:pass@localhost:1433/db',
authentication: 'ntlm',
domain: 'CORP',
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('sqlserver://user:pass@localhost:1433/db?authentication=ntlm&domain=CORP');
});

it('should merge sslrootcert field into a postgres DSN for verify-ca', () => {
const source: SourceConfig = {
id: 'test',
type: 'postgres',
dsn: 'postgres://user:pass@localhost:5432/db',
sslmode: 'verify-ca',
sslrootcert: '/etc/ssl/ca bundle.pem',
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe(
'postgres://user:pass@localhost:5432/db?sslmode=verify-ca&sslrootcert=' +
encodeURIComponent('/etc/ssl/ca bundle.pem')
);
});

it('should not merge sslrootcert when sslmode is not a verify mode', () => {
const source: SourceConfig = {
id: 'test',
type: 'postgres',
dsn: 'postgres://user:pass@localhost:5432/db',
sslmode: 'require',
sslrootcert: '/etc/ssl/ca.pem',
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('postgres://user:pass@localhost:5432/db?sslmode=require');
});

it('should not append a duplicate when the DSN has an empty-valued param', () => {
// SafeURL drops `?sslmode=`, but the raw presence check must still see it
// so we never produce an ambiguous `?sslmode=&sslmode=require`.
const source: SourceConfig = {
id: 'test',
type: 'postgres',
dsn: 'postgres://user:pass@localhost:5432/db?sslmode=',
sslmode: 'require',
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('postgres://user:pass@localhost:5432/db?sslmode=');
});

it('should not produce "?&" when the DSN ends with a bare "?"', () => {
const source: SourceConfig = {
id: 'test',
type: 'postgres',
dsn: 'postgres://user:pass@localhost:5432/db?',
sslmode: 'require',
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('postgres://user:pass@localhost:5432/db?sslmode=require');
});

it('should not add sslmode to a SQLite DSN', () => {
const source: SourceConfig = {
id: 'test',
type: 'sqlite',
dsn: 'sqlite:///path/to/db.sqlite',
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('sqlite:///path/to/db.sqlite');
});

it('should build PostgreSQL DSN from individual params', () => {
const source: SourceConfig = {
id: 'test',
Expand Down
Loading
Loading