diff --git a/.github/meta/commit.txt b/.github/meta/commit.txt index 4eb27b9268..7f60c83501 100644 --- a/.github/meta/commit.txt +++ b/.github/meta/commit.txt @@ -1,30 +1,26 @@ -fix: `altimate-dbt` compile, execute, and children commands — CLI fallbacks for dbt 1.11+ (#252) +fix: harden auth field handling for all warehouse drivers (#267) -The `@altimateai/dbt-integration` library's JSON output parsing breaks with -newer dbt versions (1.11.x) where the log format changed. Three commands -were affected: +Add config field name normalization so LLM-generated, dbt, and SDK +field names all resolve to canonical snake_case before reaching drivers. -- `execute`: `dbt show` output no longer contains `data.preview` in the - expected format — `d[0].data` throws when the filter returns empty. -- `compile`: `dbt compile` output no longer contains `data.compiled` — - same `o[0].data` pattern failure. -- `children`: `nodeMetaMap.lookupByBaseName()` fails when the manifest - file-path resolution doesn't populate the model-name lookup map. +- Add `normalizeConfig()` in `@altimateai/drivers` with per-driver alias + maps (Snowflake, BigQuery, Databricks, Redshift, PostgreSQL, MySQL, + SQL Server, Oracle) and common aliases (`username` → `user`, + `dbname` → `database`) +- Call `normalizeConfig()` before both `resolveConfig()` and + `saveConnection()` in registry so credential store uses canonical names +- Add Snowflake inline PEM support via `config.private_key` +- Add BigQuery `credentials_json` (inline JSON) support +- Add MySQL SSL construction from top-level `ssl_ca`/`ssl_cert`/`ssl_key` + fields at connection time (not during normalization, to preserve + credential store detection) +- Expand `SENSITIVE_FIELDS` with `private_key`, `token`, + `credentials_json`, `keyfile_json`, `ssl_key`, `ssl_cert`, `ssl_ca` +- Update `sensitiveKeys` in `project-scan.ts` to match +- Fix `detectAuthMethod()` to recognize inline `private_key` +- Update `warehouse_add` tool description with per-warehouse field docs +- Add 72 unit tests for normalization logic -Additionally, `tryExecuteViaDbt` in opencode incorrectly expected -`raw.table` on `QueryExecutionResult`, which actually has `{ columnNames, -columnTypes, data }` — causing the dbt-first execution path to always -fall through to native drivers silently. - -Fixes: -- Add try-catch in execute/compile/graph commands with fallback to direct - `dbt` CLI subprocess calls (`dbt show`, `dbt compile`, `dbt ls`) -- New `dbt-cli.ts` module with resilient multi-format JSON output parsing - (handles `data.preview`, `data.rows`, `data.compiled`, `data.compiled_code`, - `result.node.compiled_code`) -- Fix `tryExecuteViaDbt` to recognize `QueryExecutionResult` shape first, - then fall back to legacy `raw.table` format - -Closes #252 +Closes #267 Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/packages/drivers/src/bigquery.ts b/packages/drivers/src/bigquery.ts index e3931e9143..99e6f9f02e 100644 --- a/packages/drivers/src/bigquery.ts +++ b/packages/drivers/src/bigquery.ts @@ -21,7 +21,17 @@ export async function connect(config: ConnectionConfig): Promise { async connect() { const options: Record = {} if (config.project) options.projectId = config.project - if (config.credentials_path) options.keyFilename = config.credentials_path + if (config.credentials_json) { + try { + options.credentials = typeof config.credentials_json === "string" + ? JSON.parse(config.credentials_json as string) + : config.credentials_json + } catch (e) { + throw new Error(`Failed to parse credentials_json: ${e}`) + } + } else if (config.credentials_path) { + options.keyFilename = config.credentials_path + } if (config.location) options.location = config.location client = new BigQuery(options) diff --git a/packages/drivers/src/index.ts b/packages/drivers/src/index.ts index 871a9a7db1..c19a25f64a 100644 --- a/packages/drivers/src/index.ts +++ b/packages/drivers/src/index.ts @@ -4,6 +4,9 @@ export type { Connector, ConnectorResult, SchemaColumn, ConnectionConfig } from // Re-export escape utilities export { escapeSqlString, escapeSqlIdentifier } from "./sql-escape" +// Re-export config normalization +export { normalizeConfig } from "./normalize" + // Re-export driver connect functions export { connect as connectPostgres } from "./postgres" export { connect as connectSnowflake } from "./snowflake" diff --git a/packages/drivers/src/mysql.ts b/packages/drivers/src/mysql.ts index 9852c7ed5d..9738d25751 100644 --- a/packages/drivers/src/mysql.ts +++ b/packages/drivers/src/mysql.ts @@ -30,6 +30,12 @@ export async function connect(config: ConnectionConfig): Promise { if (config.ssl !== undefined) { poolConfig.ssl = config.ssl + } else if (config.ssl_ca || config.ssl_cert || config.ssl_key) { + const sslObj: Record = {} + if (config.ssl_ca) sslObj.ca = config.ssl_ca + if (config.ssl_cert) sslObj.cert = config.ssl_cert + if (config.ssl_key) sslObj.key = config.ssl_key + poolConfig.ssl = sslObj } pool = mysql.createPool(poolConfig) diff --git a/packages/drivers/src/normalize.ts b/packages/drivers/src/normalize.ts new file mode 100644 index 0000000000..edcb85b8c2 --- /dev/null +++ b/packages/drivers/src/normalize.ts @@ -0,0 +1,169 @@ +/** + * Config field name normalization for warehouse drivers. + * + * The warehouse_add tool takes a generic Record config. + * LLMs, dbt profiles, and SDK conventions use different field names for the + * same config values. This module normalizes them to canonical snake_case + * names before the config reaches the driver. + */ + +import type { ConnectionConfig } from "./types" + +// --------------------------------------------------------------------------- +// Per-driver alias maps +// --------------------------------------------------------------------------- +// Key = canonical field name, Value = list of aliases (checked in order). +// First-value-wins: if the canonical field is already present, aliases are +// ignored. + +type AliasMap = Record + +/** Aliases common to most drivers. */ +const COMMON_ALIASES: AliasMap = { + user: ["username"], + database: ["dbname", "db"], +} + +const SNOWFLAKE_ALIASES: AliasMap = { + ...COMMON_ALIASES, + private_key_path: ["privateKeyPath"], + private_key_passphrase: ["privateKeyPassphrase", "privateKeyPass"], + private_key: ["privateKey"], + access_token: ["token"], + oauth_client_id: ["oauthClientId"], + oauth_client_secret: ["oauthClientSecret"], +} + +const BIGQUERY_ALIASES: AliasMap = { + project: ["projectId", "project_id"], + credentials_path: ["keyfile", "keyFilename", "key_file", "keyFile"], + credentials_json: ["keyfile_json", "keyfileJson"], + dataset: ["defaultDataset", "default_dataset"], +} + +const DATABRICKS_ALIASES: AliasMap = { + server_hostname: ["host", "serverHostname"], + http_path: ["httpPath"], + access_token: ["token", "personal_access_token", "personalAccessToken"], +} + +const POSTGRES_ALIASES: AliasMap = { + ...COMMON_ALIASES, + connection_string: ["connectionString"], +} + +const REDSHIFT_ALIASES: AliasMap = { + ...COMMON_ALIASES, + connection_string: ["connectionString"], +} + +const MYSQL_ALIASES: AliasMap = { + ...COMMON_ALIASES, +} + +const SQLSERVER_ALIASES: AliasMap = { + ...COMMON_ALIASES, + host: ["server", "serverName", "server_name"], + trust_server_certificate: ["trustServerCertificate"], +} + +const ORACLE_ALIASES: AliasMap = { + ...COMMON_ALIASES, + connection_string: ["connectString", "connect_string", "connectionString"], + service_name: ["serviceName"], +} + +/** Map of warehouse type to its alias map. */ +const DRIVER_ALIASES: Record = { + snowflake: SNOWFLAKE_ALIASES, + bigquery: BIGQUERY_ALIASES, + databricks: DATABRICKS_ALIASES, + postgres: POSTGRES_ALIASES, + postgresql: POSTGRES_ALIASES, + redshift: REDSHIFT_ALIASES, + mysql: MYSQL_ALIASES, + mariadb: MYSQL_ALIASES, + sqlserver: SQLSERVER_ALIASES, + mssql: SQLSERVER_ALIASES, + oracle: ORACLE_ALIASES, + // duckdb and sqlite have simple configs — no aliases needed +} + +// --------------------------------------------------------------------------- +// Core logic +// --------------------------------------------------------------------------- + +/** + * Apply alias mappings to a config object. + * For each canonical field: if it is not already set, check each alias in + * order and use the first non-undefined value. Consumed aliases are removed + * from the output to avoid passing unexpected fields to the driver. + */ +function applyAliases(config: ConnectionConfig, aliases: AliasMap): ConnectionConfig { + const result = { ...config } + + for (const [canonical, aliasList] of Object.entries(aliases)) { + let resolved = result[canonical] !== undefined + for (const alias of aliasList) { + if (result[alias] !== undefined) { + if (!resolved) { + result[canonical] = result[alias] + resolved = true + } + delete result[alias] + } + } + } + + return result +} + +/** + * Handle Snowflake private_key: if the value doesn't look like a file path, + * keep it as private_key (inline PEM). If it looks like a path, move it to + * private_key_path. + */ +function normalizeSnowflakePrivateKey(config: ConnectionConfig): ConnectionConfig { + const pk = config.private_key + if (typeof pk !== "string" || !pk) return config + if (config.private_key_path) return config // already have a path + + // Inline PEM starts with "-----BEGIN" or contains newlines + if (pk.includes("-----BEGIN") || pk.includes("\n")) { + return config // keep as private_key (inline PEM) + } + + // Looks like a file path + if (pk.includes("/") || pk.includes("\\") || pk.endsWith(".pem") || pk.endsWith(".p8")) { + const result = { ...config } + result.private_key_path = pk + delete result.private_key + return result + } + + return config +} + +/** + * Normalize a connection config by resolving field name aliases. + * Returns a new config object; does not mutate the input. + * Unknown types pass through unchanged. + */ +export function normalizeConfig(config: ConnectionConfig): ConnectionConfig { + const type = config.type?.toLowerCase() + if (!type) return { ...config } + + const aliases = DRIVER_ALIASES[type] + let result = aliases ? applyAliases(config, aliases) : { ...config } + + // Type-specific post-processing + // Note: MySQL SSL fields (ssl_ca, ssl_cert, ssl_key) are NOT constructed + // into an ssl object here. They stay as top-level fields so the credential + // store can detect and strip them. The MySQL driver constructs the ssl + // object at connection time. + if (type === "snowflake") { + result = normalizeSnowflakePrivateKey(result) + } + + return result +} diff --git a/packages/drivers/src/snowflake.ts b/packages/drivers/src/snowflake.ts index 286b84d3e5..a6299cdcea 100644 --- a/packages/drivers/src/snowflake.ts +++ b/packages/drivers/src/snowflake.ts @@ -61,6 +61,8 @@ export async function connect(config: ConnectionConfig): Promise { // --------------------------------------------------------------- // Normalize field names: accept snake_case (dbt), camelCase (SDK), // and common LLM-generated variants so auth "just works". + // Note: normalizeConfig() in normalize.ts handles most aliases + // upstream, but these fallbacks provide defense-in-depth. // --------------------------------------------------------------- const keyPath = (config.private_key_path ?? config.privateKeyPath) as string | undefined const inlineKey = (config.private_key ?? config.privateKey) as string | undefined diff --git a/packages/opencode/src/altimate/native/connections/credential-store.ts b/packages/opencode/src/altimate/native/connections/credential-store.ts index 10be89e480..ba9433ae5b 100644 --- a/packages/opencode/src/altimate/native/connections/credential-store.ts +++ b/packages/opencode/src/altimate/native/connections/credential-store.ts @@ -26,6 +26,11 @@ const SENSITIVE_FIELDS = new Set([ "passcode", "ssh_password", "connection_string", + "credentials_json", + "keyfile_json", + "ssl_key", + "ssl_cert", + "ssl_ca", ]) /** Cached keytar module (or null if unavailable). */ diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index 7af182e956..f0826d0576 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -14,6 +14,7 @@ import * as path from "path" import * as os from "os" import { Log } from "../../../util/log" import type { ConnectionConfig, Connector } from "@altimateai/drivers" +import { normalizeConfig } from "@altimateai/drivers" import { resolveConfig, saveConnection } from "./credential-store" import { startTunnel, extractSshConfig, closeTunnel } from "./ssh-tunnel" import type { WarehouseInfo } from "../types" @@ -138,8 +139,12 @@ async function createConnector( ) } + // Normalize field names first (camelCase → snake_case, dbt → canonical) + // so credential resolution uses canonical names for keychain lookups + let resolvedConfig = normalizeConfig(config) + // Resolve credentials from keychain - let resolvedConfig = await resolveConfig(name, config) + resolvedConfig = await resolveConfig(name, resolvedConfig) // Handle SSH tunnel const sshConfig = extractSshConfig(resolvedConfig) @@ -363,8 +368,12 @@ export async function add( try { ensureLoaded() + // Normalize field names before saving so sensitive fields under alias + // names (e.g., keyfileJson → credentials_json) are properly detected + const normalized = normalizeConfig(config) + // Store credentials in keychain, get sanitized config - const { sanitized, warnings } = await saveConnection(name, config) + const { sanitized, warnings } = await saveConnection(name, normalized) // Save to global config file const globalPath = globalConfigPath() @@ -377,10 +386,10 @@ export async function add( existing[name] = sanitized fs.writeFileSync(globalPath, JSON.stringify(existing, null, 2), "utf-8") - // In-memory: keep original config (with credentials) so the current + // In-memory: keep normalized config (with credentials) so the current // session can connect even when keytar is unavailable. Only the disk // file uses the sanitized version (credentials stripped). - configs.set(name, config) + configs.set(name, normalized) // Clear cached connector const cached = connectors.get(name) diff --git a/packages/opencode/src/altimate/tools/project-scan.ts b/packages/opencode/src/altimate/tools/project-scan.ts index 48898bb921..d2abd4d525 100644 --- a/packages/opencode/src/altimate/tools/project-scan.ts +++ b/packages/opencode/src/altimate/tools/project-scan.ts @@ -227,7 +227,12 @@ export async function detectEnvVars(): Promise { const matchedSignal = wh.signals.find((s) => process.env[s]) if (!matchedSignal) continue - const sensitiveKeys = new Set(["password", "access_token", "connection_string", "private_key_path"]) + const sensitiveKeys = new Set([ + "password", "access_token", "token", "connection_string", + "private_key_path", "private_key", "private_key_passphrase", + "credentials_json", "keyfile_json", "ssl_key", "ssl_cert", "ssl_ca", + "oauth_client_secret", "passcode", + ]) const config: Record = {} for (const [key, envNames] of Object.entries(wh.configMap)) { const names = Array.isArray(envNames) ? envNames : [envNames] diff --git a/packages/opencode/src/altimate/tools/warehouse-add.ts b/packages/opencode/src/altimate/tools/warehouse-add.ts index 7127027215..5112c3d3f9 100644 --- a/packages/opencode/src/altimate/tools/warehouse-add.ts +++ b/packages/opencode/src/altimate/tools/warehouse-add.ts @@ -10,15 +10,19 @@ export const WarehouseAddTool = Tool.define("warehouse_add", { config: z .record(z.string(), z.unknown()) .describe( - 'Connection configuration. Must include "type" (postgres, snowflake, duckdb, etc). ' + - 'Snowflake auth methods: ' + - '(1) Password: {"type":"snowflake","account":"xy12345","user":"admin","password":"secret","warehouse":"WH","database":"db","schema":"public","role":"ROLE"}. ' + - '(2) Key-pair (file): {"type":"snowflake","account":"xy12345","user":"admin","private_key_path":"/path/to/rsa_key.p8","private_key_passphrase":"optional","warehouse":"WH","database":"db","schema":"public","role":"ROLE"}. ' + - '(3) Key-pair (inline): use "private_key" instead of "private_key_path" with PEM content. ' + - '(4) OAuth: {"type":"snowflake","account":"xy12345","authenticator":"oauth","token":"","warehouse":"WH","database":"db","schema":"public"}. ' + - '(5) SSO: {"type":"snowflake","account":"xy12345","user":"admin","authenticator":"externalbrowser","warehouse":"WH","database":"db","schema":"public","role":"ROLE"}. ' + - 'IMPORTANT: For private key file paths, always use "private_key_path" (not "private_key"). ' + - 'Postgres: {"type":"postgres","host":"localhost","port":5432,"database":"mydb","user":"admin","password":"secret"}.', + `Connection configuration. Must include "type". Field aliases (camelCase, dbt names) are auto-normalized. Canonical fields per type: +- postgres: host, port, database, user, password, ssl, connection_string, statement_timeout +- snowflake: account, user, password, database, schema, warehouse, role, private_key_path, private_key_passphrase, private_key (inline PEM), authenticator (oauth/externalbrowser/okta URL), token +- bigquery: project, credentials_path (service account JSON file), credentials_json (inline JSON), location, dataset +- databricks: server_hostname, http_path, access_token, catalog, schema +- redshift: host, port, database, user, password, ssl, connection_string +- mysql: host, port, database, user, password, ssl (or ssl_ca, ssl_cert, ssl_key) +- sqlserver: host, port, database, user, password, encrypt, trust_server_certificate +- oracle: connection_string (or host, port, service_name), user, password +- duckdb: path (file path or ":memory:") +- sqlite: path (file path) +Snowflake auth examples: (1) Password: {"type":"snowflake","account":"xy12345","user":"admin","password":"secret","warehouse":"WH","database":"db"}. (2) Key-pair: {"type":"snowflake","account":"xy12345","user":"admin","private_key_path":"/path/rsa_key.p8","warehouse":"WH","database":"db"}. (3) OAuth: {"type":"snowflake","account":"xy12345","authenticator":"oauth","token":"","warehouse":"WH","database":"db"}. (4) SSO: {"type":"snowflake","account":"xy12345","user":"admin","authenticator":"externalbrowser","warehouse":"WH","database":"db"}. +IMPORTANT: For private key file paths, always use "private_key_path" (not "private_key").`, ), }), async execute(args, ctx) { diff --git a/packages/opencode/test/altimate/driver-normalize.test.ts b/packages/opencode/test/altimate/driver-normalize.test.ts new file mode 100644 index 0000000000..f437e1187c --- /dev/null +++ b/packages/opencode/test/altimate/driver-normalize.test.ts @@ -0,0 +1,665 @@ +import { describe, expect, test } from "bun:test" +import { normalizeConfig } from "@altimateai/drivers" +import { isSensitiveField } from "../../src/altimate/native/connections/credential-store" + +// --------------------------------------------------------------------------- +// normalizeConfig — identity (canonical fields pass through unchanged) +// --------------------------------------------------------------------------- + +describe("normalizeConfig — identity", () => { + test("canonical postgres config passes through unchanged", () => { + const config = { + type: "postgres", + host: "localhost", + port: 5432, + database: "mydb", + user: "admin", + password: "secret", + } + expect(normalizeConfig(config)).toEqual(config) + }) + + test("canonical snowflake config passes through unchanged", () => { + const config = { + type: "snowflake", + account: "xy12345", + user: "admin", + password: "secret", + database: "MYDB", + warehouse: "COMPUTE_WH", + } + expect(normalizeConfig(config)).toEqual(config) + }) + + test("unknown type passes through unchanged", () => { + const config = { type: "unknown_db", foo: "bar", baz: 42 } + expect(normalizeConfig(config)).toEqual(config) + }) + + test("missing type passes through unchanged", () => { + const config = { type: "", host: "localhost" } + expect(normalizeConfig(config)).toEqual(config) + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — first-value-wins +// --------------------------------------------------------------------------- + +describe("normalizeConfig — first-value-wins", () => { + test("canonical field takes precedence over alias", () => { + const config = { + type: "postgres", + user: "correct", + username: "wrong", + } + const result = normalizeConfig(config) + expect(result.user).toBe("correct") + expect(result.username).toBeUndefined() + }) + + test("canonical database takes precedence over dbname", () => { + const config = { + type: "mysql", + database: "correct_db", + dbname: "wrong_db", + } + const result = normalizeConfig(config) + expect(result.database).toBe("correct_db") + expect(result.dbname).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — common aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — common aliases", () => { + test("username → user for postgres", () => { + const result = normalizeConfig({ + type: "postgres", + username: "admin", + }) + expect(result.user).toBe("admin") + expect(result.username).toBeUndefined() + }) + + test("dbname → database for postgres", () => { + const result = normalizeConfig({ + type: "postgres", + dbname: "mydb", + }) + expect(result.database).toBe("mydb") + expect(result.dbname).toBeUndefined() + }) + + test("db → database for mysql", () => { + const result = normalizeConfig({ + type: "mysql", + db: "mydb", + }) + expect(result.database).toBe("mydb") + expect(result.db).toBeUndefined() + }) + + test("username → user for redshift", () => { + const result = normalizeConfig({ + type: "redshift", + username: "admin", + }) + expect(result.user).toBe("admin") + expect(result.username).toBeUndefined() + }) + + test("username → user for oracle", () => { + const result = normalizeConfig({ + type: "oracle", + username: "admin", + }) + expect(result.user).toBe("admin") + expect(result.username).toBeUndefined() + }) + + test("username → user for sqlserver", () => { + const result = normalizeConfig({ + type: "sqlserver", + username: "sa", + }) + expect(result.user).toBe("sa") + expect(result.username).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — Snowflake aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — Snowflake", () => { + test("privateKeyPath → private_key_path", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + privateKeyPath: "/path/to/key.p8", + }) + expect(result.private_key_path).toBe("/path/to/key.p8") + expect(result.privateKeyPath).toBeUndefined() + }) + + test("privateKeyPassphrase → private_key_passphrase", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + privateKeyPassphrase: "secret", + }) + expect(result.private_key_passphrase).toBe("secret") + expect(result.privateKeyPassphrase).toBeUndefined() + }) + + test("privateKey → private_key", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + privateKey: "-----BEGIN PRIVATE KEY-----\nMIIE...", + }) + expect(result.private_key).toBe("-----BEGIN PRIVATE KEY-----\nMIIE...") + expect(result.privateKey).toBeUndefined() + }) + + test("private_key with file path → private_key_path", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + private_key: "/home/user/.ssh/snowflake_key.p8", + }) + expect(result.private_key_path).toBe("/home/user/.ssh/snowflake_key.p8") + expect(result.private_key).toBeUndefined() + }) + + test("private_key with .pem extension → private_key_path", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + private_key: "rsa_key.pem", + }) + expect(result.private_key_path).toBe("rsa_key.pem") + expect(result.private_key).toBeUndefined() + }) + + test("private_key with inline PEM stays as private_key", () => { + const pem = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg..." + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + private_key: pem, + }) + expect(result.private_key).toBe(pem) + expect(result.private_key_path).toBeUndefined() + }) + + test("private_key_path takes precedence over private_key path detection", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + private_key_path: "/correct/path.p8", + private_key: "/wrong/path.p8", + }) + expect(result.private_key_path).toBe("/correct/path.p8") + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — BigQuery aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — BigQuery", () => { + test("projectId → project", () => { + const result = normalizeConfig({ + type: "bigquery", + projectId: "my-project", + }) + expect(result.project).toBe("my-project") + expect(result.projectId).toBeUndefined() + }) + + test("project_id → project", () => { + const result = normalizeConfig({ + type: "bigquery", + project_id: "my-project", + }) + expect(result.project).toBe("my-project") + expect(result.project_id).toBeUndefined() + }) + + test("keyFilename → credentials_path", () => { + const result = normalizeConfig({ + type: "bigquery", + keyFilename: "/path/to/key.json", + }) + expect(result.credentials_path).toBe("/path/to/key.json") + expect(result.keyFilename).toBeUndefined() + }) + + test("keyfile → credentials_path", () => { + const result = normalizeConfig({ + type: "bigquery", + keyfile: "/path/to/key.json", + }) + expect(result.credentials_path).toBe("/path/to/key.json") + expect(result.keyfile).toBeUndefined() + }) + + test("key_file → credentials_path", () => { + const result = normalizeConfig({ + type: "bigquery", + key_file: "/path/to/key.json", + }) + expect(result.credentials_path).toBe("/path/to/key.json") + expect(result.key_file).toBeUndefined() + }) + + test("keyFile → credentials_path", () => { + const result = normalizeConfig({ + type: "bigquery", + keyFile: "/path/to/key.json", + }) + expect(result.credentials_path).toBe("/path/to/key.json") + expect(result.keyFile).toBeUndefined() + }) + + test("keyfile_json → credentials_json", () => { + const result = normalizeConfig({ + type: "bigquery", + keyfile_json: '{"key": "value"}', + }) + expect(result.credentials_json).toBe('{"key": "value"}') + expect(result.keyfile_json).toBeUndefined() + }) + + test("keyfileJson → credentials_json", () => { + const result = normalizeConfig({ + type: "bigquery", + keyfileJson: '{"key": "value"}', + }) + expect(result.credentials_json).toBe('{"key": "value"}') + expect(result.keyfileJson).toBeUndefined() + }) + + test("defaultDataset → dataset", () => { + const result = normalizeConfig({ + type: "bigquery", + defaultDataset: "my_dataset", + }) + expect(result.dataset).toBe("my_dataset") + expect(result.defaultDataset).toBeUndefined() + }) + + test("default_dataset → dataset", () => { + const result = normalizeConfig({ + type: "bigquery", + default_dataset: "my_dataset", + }) + expect(result.dataset).toBe("my_dataset") + expect(result.default_dataset).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — Databricks aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — Databricks", () => { + test("host → server_hostname (databricks only)", () => { + const result = normalizeConfig({ + type: "databricks", + host: "my-workspace.cloud.databricks.com", + }) + expect(result.server_hostname).toBe("my-workspace.cloud.databricks.com") + expect(result.host).toBeUndefined() + }) + + test("serverHostname → server_hostname", () => { + const result = normalizeConfig({ + type: "databricks", + serverHostname: "my-workspace.cloud.databricks.com", + }) + expect(result.server_hostname).toBe("my-workspace.cloud.databricks.com") + expect(result.serverHostname).toBeUndefined() + }) + + test("httpPath → http_path", () => { + const result = normalizeConfig({ + type: "databricks", + httpPath: "/sql/1.0/endpoints/abc", + }) + expect(result.http_path).toBe("/sql/1.0/endpoints/abc") + expect(result.httpPath).toBeUndefined() + }) + + test("token → access_token", () => { + const result = normalizeConfig({ + type: "databricks", + token: "dapi1234", + }) + expect(result.access_token).toBe("dapi1234") + expect(result.token).toBeUndefined() + }) + + test("personal_access_token → access_token", () => { + const result = normalizeConfig({ + type: "databricks", + personal_access_token: "dapi1234", + }) + expect(result.access_token).toBe("dapi1234") + expect(result.personal_access_token).toBeUndefined() + }) + + test("personalAccessToken → access_token", () => { + const result = normalizeConfig({ + type: "databricks", + personalAccessToken: "dapi1234", + }) + expect(result.access_token).toBe("dapi1234") + expect(result.personalAccessToken).toBeUndefined() + }) + + test("host alias does NOT apply to postgres", () => { + const result = normalizeConfig({ + type: "postgres", + host: "localhost", + }) + expect(result.host).toBe("localhost") + expect(result.server_hostname).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — PostgreSQL / Redshift aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — PostgreSQL / Redshift", () => { + test("connectionString → connection_string for postgres", () => { + const result = normalizeConfig({ + type: "postgres", + connectionString: "postgresql://user:pass@host/db", + }) + expect(result.connection_string).toBe("postgresql://user:pass@host/db") + expect(result.connectionString).toBeUndefined() + }) + + test("connectionString → connection_string for redshift", () => { + const result = normalizeConfig({ + type: "redshift", + connectionString: "postgresql://user:pass@host/db", + }) + expect(result.connection_string).toBe("postgresql://user:pass@host/db") + expect(result.connectionString).toBeUndefined() + }) + + test("postgresql type alias works", () => { + const result = normalizeConfig({ + type: "postgresql", + username: "admin", + dbname: "mydb", + }) + expect(result.user).toBe("admin") + expect(result.database).toBe("mydb") + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — SQL Server aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — SQL Server", () => { + test("server → host", () => { + const result = normalizeConfig({ + type: "sqlserver", + server: "myserver.database.windows.net", + }) + expect(result.host).toBe("myserver.database.windows.net") + expect(result.server).toBeUndefined() + }) + + test("serverName → host", () => { + const result = normalizeConfig({ + type: "sqlserver", + serverName: "myserver", + }) + expect(result.host).toBe("myserver") + expect(result.serverName).toBeUndefined() + }) + + test("server_name → host", () => { + const result = normalizeConfig({ + type: "sqlserver", + server_name: "myserver", + }) + expect(result.host).toBe("myserver") + expect(result.server_name).toBeUndefined() + }) + + test("trustServerCertificate → trust_server_certificate", () => { + const result = normalizeConfig({ + type: "sqlserver", + trustServerCertificate: true, + }) + expect(result.trust_server_certificate).toBe(true) + expect(result.trustServerCertificate).toBeUndefined() + }) + + test("mssql type alias works", () => { + const result = normalizeConfig({ + type: "mssql", + server: "myserver", + username: "sa", + }) + expect(result.host).toBe("myserver") + expect(result.user).toBe("sa") + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — Oracle aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — Oracle", () => { + test("connectString → connection_string", () => { + const result = normalizeConfig({ + type: "oracle", + connectString: "localhost:1521/ORCL", + }) + expect(result.connection_string).toBe("localhost:1521/ORCL") + expect(result.connectString).toBeUndefined() + }) + + test("connect_string → connection_string", () => { + const result = normalizeConfig({ + type: "oracle", + connect_string: "localhost:1521/ORCL", + }) + expect(result.connection_string).toBe("localhost:1521/ORCL") + expect(result.connect_string).toBeUndefined() + }) + + test("connectionString → connection_string", () => { + const result = normalizeConfig({ + type: "oracle", + connectionString: "localhost:1521/ORCL", + }) + expect(result.connection_string).toBe("localhost:1521/ORCL") + expect(result.connectionString).toBeUndefined() + }) + + test("serviceName → service_name", () => { + const result = normalizeConfig({ + type: "oracle", + serviceName: "ORCL", + }) + expect(result.service_name).toBe("ORCL") + expect(result.serviceName).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — MySQL SSL fields stay top-level +// --------------------------------------------------------------------------- +// SSL fields (ssl_ca, ssl_cert, ssl_key) are NOT constructed into an ssl +// object by normalizeConfig. They stay top-level so the credential store can +// detect and strip them. The MySQL driver constructs the ssl object at +// connection time. + +describe("normalizeConfig — MySQL SSL fields", () => { + test("ssl_ca/ssl_cert/ssl_key stay as top-level fields", () => { + const result = normalizeConfig({ + type: "mysql", + host: "localhost", + ssl_ca: "/path/ca.pem", + ssl_cert: "/path/client-cert.pem", + ssl_key: "/path/client-key.pem", + }) + expect(result.ssl_ca).toBe("/path/ca.pem") + expect(result.ssl_cert).toBe("/path/client-cert.pem") + expect(result.ssl_key).toBe("/path/client-key.pem") + expect(result.ssl).toBeUndefined() + }) + + test("existing ssl object is preserved", () => { + const sslObj = { rejectUnauthorized: false } + const result = normalizeConfig({ + type: "mysql", + ssl: sslObj, + }) + expect(result.ssl).toEqual(sslObj) + }) + + test("mariadb ssl fields also stay top-level", () => { + const result = normalizeConfig({ + type: "mariadb", + ssl_ca: "/path/ca.pem", + }) + expect(result.ssl_ca).toBe("/path/ca.pem") + expect(result.ssl).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// isSensitiveField — expanded set +// --------------------------------------------------------------------------- + +describe("isSensitiveField — expanded set", () => { + const expectedSensitive = [ + "password", + "private_key", + "privateKey", + "private_key_passphrase", + "privateKeyPassphrase", + "privateKeyPass", + "access_token", + "token", + "oauth_client_secret", + "oauthClientSecret", + "passcode", + "ssh_password", + "connection_string", + "credentials_json", + "keyfile_json", + "ssl_key", + "ssl_cert", + "ssl_ca", + ] + + for (const field of expectedSensitive) { + test(`${field} is sensitive`, () => { + expect(isSensitiveField(field)).toBe(true) + }) + } + + test("host is not sensitive", () => { + expect(isSensitiveField("host")).toBe(false) + }) + + test("user is not sensitive", () => { + expect(isSensitiveField("user")).toBe(false) + }) + + test("database is not sensitive", () => { + expect(isSensitiveField("database")).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — does not mutate input +// --------------------------------------------------------------------------- + +describe("normalizeConfig — immutability", () => { + test("input config is not mutated", () => { + const original = { + type: "postgres", + username: "admin", + dbname: "mydb", + } + const copy = { ...original } + normalizeConfig(original) + expect(original).toEqual(copy) + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — case-insensitive type +// --------------------------------------------------------------------------- + +describe("normalizeConfig — case-insensitive type", () => { + test("uppercase POSTGRES normalizes aliases", () => { + const result = normalizeConfig({ type: "POSTGRES", username: "admin" }) + expect(result.user).toBe("admin") + expect(result.username).toBeUndefined() + }) + + test("mixed case Snowflake normalizes aliases", () => { + const result = normalizeConfig({ + type: "Snowflake", + account: "xy12345", + privateKeyPath: "/path/key.p8", + }) + expect(result.private_key_path).toBe("/path/key.p8") + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — DuckDB/SQLite passthrough +// --------------------------------------------------------------------------- + +describe("normalizeConfig — DuckDB/SQLite passthrough", () => { + test("duckdb config passes through unchanged", () => { + const config = { type: "duckdb", path: ":memory:" } + expect(normalizeConfig(config)).toEqual(config) + }) + + test("sqlite config passes through unchanged", () => { + const config = { type: "sqlite", path: "/tmp/test.db" } + expect(normalizeConfig(config)).toEqual(config) + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — Snowflake private_key edge cases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — Snowflake private_key edge cases", () => { + test("opaque string without path indicators stays as private_key", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + private_key: "some-opaque-token-value", + }) + expect(result.private_key).toBe("some-opaque-token-value") + expect(result.private_key_path).toBeUndefined() + }) +})