From 25925cbb8ea2b0c18be952312e56fadad3a92396 Mon Sep 17 00:00:00 2001 From: Madhavendra Rathore Date: Mon, 1 Jun 2026 08:21:47 +0000 Subject: [PATCH 1/2] feat(sea): SEA connect + auth (PAT + OAuth M2M/U2M) [1/3] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of three stacked PRs splitting the SEA foundation (was the single 8/8 PR #383). This PR establishes a SEA-backed connection and session: - SeaBackend: connect() validates auth + captures the napi ConnectionOptions; openSession() folds catalog/schema/sessionConf and opens a kernel session. - SeaAuth: PAT + OAuth M2M + OAuth U2M validation/routing (mirrors the DBSQLClient auth-validation pattern; slash-prepended httpPath via prependSlash). - SeaErrorMapping: kernel ErrorCode → JS error-class mapping. - SeaSessionBackend: session open/close. executeStatement + metadata methods throw a clear deferred error — wired in [2/3] SEA execution + results. - DBSQLClient: route `useSEA: true` to the real SeaBackend (with IClientContext). - native/sea: the napi-rs binding surface (.d.ts + router); the .node stays gitignored (CI does not build it, loader/version tests skip when absent). Tests: PAT / M2M / U2M / edge-case auth suites, kernel error mapping, and the DBSQLClient SEA-routing + partial-init guard. Drops the obsolete stub SeaBackend.test (real backend is covered by the auth suites). Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore --- .eslintrc | 6 + lib/DBSQLClient.ts | 2 +- lib/errors/AuthenticationError.ts | 7 +- lib/sea/SeaAuth.ts | 286 ++++++++++++ lib/sea/SeaBackend.ts | 130 +++++- lib/sea/SeaErrorMapping.ts | 289 ++++++++++++ lib/sea/SeaSessionBackend.ts | 168 +++++++ lib/utils/prependSlash.ts | 25 + native/sea/index.d.ts | 194 +++++--- native/sea/index.js | 39 +- tests/e2e/sea/auth-m2m-e2e.test.ts | 120 +++++ tests/e2e/sea/auth-pat-e2e.test.ts | 79 ++++ tests/e2e/sea/auth-u2m-e2e.test.ts | 73 +++ tests/unit/DBSQLClient.test.ts | 13 +- tests/unit/sea/SeaBackend.test.ts | 39 -- tests/unit/sea/_helpers/fakeBinding.ts | 63 +++ tests/unit/sea/auth-edge-cases.test.ts | 614 +++++++++++++++++++++++++ tests/unit/sea/auth-m2m.test.ts | 197 ++++++++ tests/unit/sea/auth-pat.test.ts | 130 ++++++ tests/unit/sea/auth-u2m.test.ts | 143 ++++++ tests/unit/sea/error-mapping.test.ts | 221 +++++++++ 21 files changed, 2710 insertions(+), 128 deletions(-) create mode 100644 lib/sea/SeaAuth.ts create mode 100644 lib/sea/SeaErrorMapping.ts create mode 100644 lib/sea/SeaSessionBackend.ts create mode 100644 lib/utils/prependSlash.ts create mode 100644 tests/e2e/sea/auth-m2m-e2e.test.ts create mode 100644 tests/e2e/sea/auth-pat-e2e.test.ts create mode 100644 tests/e2e/sea/auth-u2m-e2e.test.ts delete mode 100644 tests/unit/sea/SeaBackend.test.ts create mode 100644 tests/unit/sea/_helpers/fakeBinding.ts create mode 100644 tests/unit/sea/auth-edge-cases.test.ts create mode 100644 tests/unit/sea/auth-m2m.test.ts create mode 100644 tests/unit/sea/auth-pat.test.ts create mode 100644 tests/unit/sea/auth-u2m.test.ts create mode 100644 tests/unit/sea/error-mapping.test.ts diff --git a/.eslintrc b/.eslintrc index ba0e8a85..87a23b0c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,12 @@ "rules": { "class-methods-use-this": "off", "no-underscore-dangle": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "ignoreRestSiblings": true } + ], + "@typescript-eslint/no-use-before-define": ["error", { "functions": false }], + "no-continue": "off", "consistent-return": "off", "no-param-reassign": "off", "no-bitwise": "off", diff --git a/lib/DBSQLClient.ts b/lib/DBSQLClient.ts index 5b3b176c..f49b19b3 100644 --- a/lib/DBSQLClient.ts +++ b/lib/DBSQLClient.ts @@ -628,7 +628,7 @@ export default class DBSQLClient extends EventEmitter implements IDBSQLClient, I // pattern (see databricks-sql-python/src/databricks/sql/session.py). const internalOptions = options as ConnectionOptions & InternalConnectionOptions; const backend = internalOptions.useSEA - ? new SeaBackend() + ? new SeaBackend({ context: this }) : new ThriftBackend({ context: this, onConnectionEvent: (event, payload) => this.forwardConnectionEvent(event, payload), diff --git a/lib/errors/AuthenticationError.ts b/lib/errors/AuthenticationError.ts index 54b3783c..c8588fa0 100644 --- a/lib/errors/AuthenticationError.ts +++ b/lib/errors/AuthenticationError.ts @@ -1,3 +1,8 @@ import HiveDriverError from './HiveDriverError'; -export default class AuthenticationError extends HiveDriverError {} +export default class AuthenticationError extends HiveDriverError { + constructor(message?: string) { + super(message); + this.name = 'AuthenticationError'; + } +} diff --git a/lib/sea/SeaAuth.ts b/lib/sea/SeaAuth.ts new file mode 100644 index 00000000..5f357131 --- /dev/null +++ b/lib/sea/SeaAuth.ts @@ -0,0 +1,286 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ConnectionOptions } from '../contracts/IDBSQLClient'; +import AuthenticationError from '../errors/AuthenticationError'; +import HiveDriverError from '../errors/HiveDriverError'; + +/** + * Default local listener port for the U2M authorization-code callback. + * Hardcoded here so the override of the kernel default (8020) to the + * thrift default (8030) is invariant for SEA callers — preserving parity + * with the existing Node driver. Not exposed on the public + * `ConnectionOptions` (thrift hides `callbackPorts` from its public + * surface too — see nodejs-thrift-expert survey §B.2). + */ +const U2M_DEFAULT_REDIRECT_PORT = 8030; + +/** + * Shape consumed by the napi-binding's `openSession()` (see + * `native/sea/index.d.ts`). Mirrors `ConnectionOptions` in the binding's + * `.d.ts`; declared locally to avoid coupling the JS-side adapter to the + * auto-generated TS file. + * + * Discriminated by `authMode`: + * - `'Pat'` → `token` is the PAT. + * - `'OAuthM2m'` → `oauthClientId` + `oauthClientSecret` drive a + * kernel-side client_credentials exchange. + * - `'OAuthU2m'` → `oauthRedirectPort` overrides the kernel default; + * everything else (client_id, scopes, callback timeout, + * token_url_override) uses kernel defaults. + * + * The `authMode` string literals MUST match the napi-emitted `AuthMode` + * variant names verbatim (`'Pat'`, `'OAuthM2m'`, `'OAuthU2m'` — napi-rs's + * `#[napi(string_enum)]` without an explicit case option emits the + * Rust variant identifier as-is). We duplicate the values here instead + * of importing `AuthMode` from `native/sea/index.d.ts` because that + * file declares `AuthMode` as `export const enum`, which is + * incompatible with `isolatedModules` and a runtime-coupling hazard. + * The Rust source of truth lives at `native/sea/src/database.rs`. + */ +/** + * Session-level defaults shared across all auth-mode variants. + * + * Mirrors `ConnectionOptions.catalog` / `.schema` / `.sessionConf` on + * the napi binding (kernel `Session::builder().defaults(DefaultOpts)` + * and `.session_conf(HashMap)` — the routes that actually populate SEA + * `CreateSession.catalog` / `.schema` / `.session_confs`). + * + * Per-statement overrides do not exist on the kernel surface; both + * pyo3 and napi expose catalog / schema / sessionConf only at session + * creation. Mirror that here so the adapter doesn't promise a + * capability the binding can't honour. + */ +export interface SeaSessionDefaults { + catalog?: string; + schema?: string; + sessionConf?: Record; +} + +export type SeaNativeConnectionOptions = SeaSessionDefaults & + ( + | { + hostName: string; + httpPath: string; + authMode: 'Pat'; + token: string; + } + | { + hostName: string; + httpPath: string; + authMode: 'OAuthM2m'; + oauthClientId: string; + oauthClientSecret: string; + } + | { + hostName: string; + httpPath: string; + authMode: 'OAuthU2m'; + oauthRedirectPort: number; + } + ); + +function prependSlash(str: string): string { + if (str.length > 0 && str.charAt(0) !== '/') { + return `/${str}`; + } + return str; +} + +/** + * Reject inputs that pass `typeof === 'string' && length > 0` but are + * structurally useless as credentials: whitespace-only strings, and the + * literal strings `'undefined'` / `'null'` (case-insensitive) that buggy + * shell exports (e.g. `export FOO="$UNSET_VAR"`) produce. Surfacing + * these here means an OAuth flow's `invalid_client` from the workspace + * is always a real credential mismatch, never a malformed-input passthrough. + * + * Exported so the integration-test env-gate can reuse the same predicate + * and stay in lockstep with production (B-3 fix). + */ +export function isBlankOrReserved(s: string): boolean { + const normalized = s.trim().toLowerCase(); + return normalized.length === 0 || normalized === 'undefined' || normalized === 'null'; +} + +/** + * Validate the user-supplied `ConnectionOptions` and build the + * napi-binding's connection-options shape. + * + * Supported auth modes: + * - PAT: `authType: 'access-token'` (or undefined, which already means + * PAT throughout the existing driver — see + * `DBSQLClient.createAuthProvider`). + * - OAuth M2M: `authType: 'databricks-oauth'` + `oauthClientId` + + * `oauthClientSecret`. Kernel handles OIDC discovery, client_credentials + * exchange, and re-auth on expiry internally. + * - OAuth U2M: `authType: 'databricks-oauth'` + NO `oauthClientId` and + * NO `oauthClientSecret`. Kernel runs the PKCE auth-code dance (opens + * a browser, listens on localhost:8030, exchanges the code, persists + * to `~/.config/databricks-sql-kernel/oauth/{sha256}.json`). The flow + * selector keys off `oauthClientId` presence: present → M2M, absent → + * U2M. (Round-4 NF3-2 fix; previously secret-keyed — that variant + * routed a typo'd-secret M2M call to the U2M arm and swallowed the + * actionable error.) Mirrors thrift's intent at `DBSQLClient.ts:143`. + * + * Out of scope on the OAuth paths (rejected with a clear error): + * - `azureTenantId` / `useDatabricksOAuthInAzure` → Microsoft Entra + * direct flow. The kernel uses workspace-OIDC discovery (which works + * against Azure workspaces too — they serve `/oidc/.well-known/...`) + * and does not implement the Entra-direct scope-rewrite path. + * - `persistence` on M2M → M2M tokens are not cached (re-issuing is + * cheap; no refresh token). + * - `persistence` on U2M → custom token store is a parity gap; + * requires kernel-side `AuthConfig::External` plumbing. The kernel's + * auto-disk-cache works for the standard flow today. + * + * Ambiguity: + * - PAT path: rejects when OAuth fields (`oauthClientId` / + * `oauthClientSecret`) are simultaneously set. + * - OAuth path: rejects when `token` is set alongside OAuth fields. + * + * Throws: + * - `AuthenticationError` for missing/blank required credentials. + * - `HiveDriverError` for unsupported auth modes / Azure-direct / + * custom persistence / ambiguous combinations. + */ +export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNativeConnectionOptions { + const { authType } = options as { authType?: string }; + + const base = { + hostName: options.host, + httpPath: prependSlash(options.path), + }; + + const oauth = options as { + oauthClientId?: string; + oauthClientSecret?: string; + azureTenantId?: string; + useDatabricksOAuthInAzure?: boolean; + persistence?: unknown; + }; + + if (authType === undefined || authType === 'access-token') { + const { token } = options as { token?: string }; + if (typeof token !== 'string' || isBlankOrReserved(token)) { + throw new AuthenticationError( + "SEA backend: a non-empty PAT must be supplied via `token` when using `authType: 'access-token'`.", + ); + } + if (oauth.oauthClientId !== undefined || oauth.oauthClientSecret !== undefined) { + throw new HiveDriverError( + 'SEA backend: cannot supply both `token` and `oauthClientId`/`oauthClientSecret` ' + + "on the same connection. Pick one: 'access-token' (PAT) uses `token`; " + + "'databricks-oauth' uses the OAuth fields.", + ); + } + return { ...base, authMode: 'Pat', token }; + } + + if (authType === 'databricks-oauth') { + if ((options as { token?: string }).token !== undefined) { + throw new HiveDriverError( + "SEA backend: cannot supply `token` alongside `authType: 'databricks-oauth'`. " + + "Use `authType: 'access-token'` for PAT, or omit `token` to use OAuth.", + ); + } + + if (oauth.azureTenantId !== undefined || oauth.useDatabricksOAuthInAzure === true) { + throw new HiveDriverError( + 'SEA backend: Azure-direct OAuth (azureTenantId / useDatabricksOAuthInAzure) ' + + 'is not supported. The workspace-OIDC discovery path handles Azure workspaces ' + + 'today without these options.', + ); + } + + // Flow selector mirrors thrift's `DBSQLClient.createAuthProvider` + // (`DBSQLClient.ts:143`): presence of `oauthClientId` indicates M2M + // intent, otherwise U2M. Routing decision is based on `oauthClientId` + // (the "do I have an id?" signal) rather than the secret, so a + // user who set an id but typoed/forgot the secret gets the M2M + // "secret is required" error instead of a U2M error that hides + // their actual intent. The U2M arm still defends against an id + // sneaking through: fires only when `oauthClientId` is provided as + // a blank-reserved literal (e.g., whitespace, `"null"`, `"undefined"`) + // alongside an absent/blank secret — both `idIsBlank` and + // `secretIsBlank` are true so U2M wins routing, but the caller's + // intent to use U2M with a partially-set id is ambiguous and + // rejected explicitly. + const idIsBlank = + oauth.oauthClientId === undefined || + (typeof oauth.oauthClientId === 'string' && isBlankOrReserved(oauth.oauthClientId)); + const secretIsBlank = + oauth.oauthClientSecret === undefined || + (typeof oauth.oauthClientSecret === 'string' && isBlankOrReserved(oauth.oauthClientSecret)); + + if (idIsBlank && secretIsBlank) { + // U2M — neither id nor secret supplied. + if (oauth.oauthClientId !== undefined) { + // Defense-in-depth: id was set but blank/reserved literal. + // The kernel hardcodes `client_id = "databricks-cli"` for U2M; + // there's no JS-side override knob. + throw new HiveDriverError( + 'SEA backend: `oauthClientId` is not supported on the OAuth U2M flow; ' + + "the kernel uses the built-in 'databricks-cli' client. " + + 'Omit `oauthClientId` for U2M, or supply `oauthClientSecret` for the M2M flow.', + ); + } + if (oauth.persistence !== undefined) { + throw new HiveDriverError( + 'SEA backend: `persistence` (custom OAuth token store) is not yet wired through ' + + 'to the kernel — requires `AuthConfig::External` plumbing. ' + + 'Today the kernel auto-persists U2M tokens to ' + + '`~/.config/databricks-sql-kernel/oauth/` which works for the standard flow; ' + + "the JS-supplied hook (matching thrift's `OAuthPersistence` interface) lands " + + 'when the kernel exposes it.', + ); + } + return { + ...base, + authMode: 'OAuthU2m', + oauthRedirectPort: U2M_DEFAULT_REDIRECT_PORT, + }; + } + + // M2M. + if (typeof oauth.oauthClientId !== 'string' || isBlankOrReserved(oauth.oauthClientId)) { + throw new AuthenticationError( + 'SEA backend: `oauthClientId` is required (non-empty, non-whitespace) for OAuth M2M.', + ); + } + if (typeof oauth.oauthClientSecret !== 'string' || isBlankOrReserved(oauth.oauthClientSecret)) { + throw new AuthenticationError( + 'SEA backend: `oauthClientSecret` must be a non-empty non-whitespace string for OAuth M2M.', + ); + } + if (oauth.persistence !== undefined) { + throw new HiveDriverError( + 'SEA backend: `persistence` is not supported on OAuth M2M ' + + '(M2M tokens have no refresh token; the kernel re-issues on expiry).', + ); + } + return { + ...base, + authMode: 'OAuthM2m', + oauthClientId: oauth.oauthClientId, + oauthClientSecret: oauth.oauthClientSecret, + }; + } + + throw new HiveDriverError( + `SEA backend: unsupported auth mode '${authType}'. ` + + "Supported modes on the SEA backend today: 'access-token' (PAT) and 'databricks-oauth' " + + '(M2M with oauthClientId+oauthClientSecret, or U2M with neither).', + ); +} diff --git a/lib/sea/SeaBackend.ts b/lib/sea/SeaBackend.ts index 43958679..472d7553 100644 --- a/lib/sea/SeaBackend.ts +++ b/lib/sea/SeaBackend.ts @@ -1,23 +1,135 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import IBackend from '../contracts/IBackend'; import ISessionBackend from '../contracts/ISessionBackend'; +import IClientContext from '../contracts/IClientContext'; import { ConnectionOptions, OpenSessionRequest } from '../contracts/IDBSQLClient'; import HiveDriverError from '../errors/HiveDriverError'; +import { getSeaNative, SeaNativeBinding, SeaConnection } from './SeaNativeLoader'; +import { decodeNapiKernelError } from './SeaErrorMapping'; +import { buildSeaConnectionOptions, SeaNativeConnectionOptions } from './SeaAuth'; +import SeaSessionBackend from './SeaSessionBackend'; -const NOT_IMPLEMENTED = 'SEA backend not implemented yet — wired in sea-napi-binding feature'; +export interface SeaBackendOptions { + /** + * Optional in the type so unit tests that only exercise the auth- + * routing surface (which doesn't touch context) can pass + * `{ nativeBinding }`. The constructor downcasts undefined to + * `IClientContext` because runtime callers from `DBSQLClient` always + * supply one — see `lib/DBSQLClient.ts` SEA seam. + */ + context?: IClientContext; + /** + * Optional injection seam for unit tests. When provided, replaces the + * default `getSeaNative()` call so tests can swap in a mock napi + * binding without loading the `.node` artifact. + */ + nativeBinding?: SeaNativeBinding; +} +/** + * SEA-backed implementation of `IBackend`. + * + * **M0 dispatch model:** the napi binding's `openSession()` already + * builds a kernel `Session` from PAT + hostname + httpPath, so there is + * no "connect" round-trip before `openSession` — `connect()` only + * captures the `ConnectionOptions` and validates that PAT auth is in + * use. The actual session open happens inside `openSession()`. + * + * **Auth validation:** delegates to `buildSeaConnectionOptions` from + * `SeaAuth`, which mirrors the existing DBSQLClient validation pattern + * (slash-prepended httpPath, AuthenticationError on missing token or + * blank OAuth credentials, HiveDriverError on unsupported authType / + * Azure-direct / ambiguous credential combinations). M2M and U2M + * routing key off `oauthClientId` presence; see SeaAuth.ts. + * + * **Why we don't use IClientContext's connectionProvider here:** that + * provider is the Thrift HTTP transport. The kernel owns its own + * reqwest+rustls stack inside the native binding, so there is no + * NodeJS-level connection state to manage on the SEA path. The + * `IClientContext` is still useful for logger + config access. + */ export default class SeaBackend implements IBackend { - // eslint-disable-next-line @typescript-eslint/no-unused-vars, class-methods-use-this + private readonly context: IClientContext; + + private readonly binding: SeaNativeBinding; + + private nativeOptions?: SeaNativeConnectionOptions; + + constructor(options?: SeaBackendOptions) { + this.context = options?.context as IClientContext; + this.binding = options?.nativeBinding ?? getSeaNative(); + } + public async connect(options: ConnectionOptions): Promise { - throw new HiveDriverError(NOT_IMPLEMENTED); + // Validate PAT auth + capture the napi-binding option shape. + // Any non-PAT mode (or a missing/empty token) throws here, before + // we ever touch the native binding. + this.nativeOptions = buildSeaConnectionOptions(options); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars, class-methods-use-this public async openSession(request: OpenSessionRequest): Promise { - throw new HiveDriverError(NOT_IMPLEMENTED); + if (!this.nativeOptions) { + throw new HiveDriverError('SeaBackend: not connected. Call connect() first.'); + } + + // Fold session-level defaults from the OpenSessionRequest into the + // napi `ConnectionOptions`. The kernel routes these through + // `Session::builder().defaults(DefaultOpts)` + `.session_conf(...)` + // so they land on the SEA `CreateSession` wire fields, not on each + // per-statement request. Matches pyo3's `Session.__new__` shape. + // + // Only set the optional keys when present so the napi call shape + // stays minimal — keeps wire snapshots / test assertions stable + // for callers who pass no defaults. + const sessionOptions: SeaNativeConnectionOptions = { ...this.nativeOptions }; + if (request.initialCatalog !== undefined) { + sessionOptions.catalog = request.initialCatalog; + } + if (request.initialSchema !== undefined) { + sessionOptions.schema = request.initialSchema; + } + if (request.configuration !== undefined) { + sessionOptions.sessionConf = { ...request.configuration }; + } + + let nativeConnection: SeaConnection; + try { + // `SeaNativeConnectionOptions.authMode` is a string-literal union + // ('Pat' | 'OAuthM2m' | 'OAuthU2m') — deliberately not the binding's + // `const enum AuthMode` (see SeaAuth's note on why a const-enum import + // is avoided). The literal values are byte-identical to the enum's, so + // the only divergence is TS's const-enum strictness; cast to the + // binding's parameter type at this single boundary. + nativeConnection = (await this.binding.openSession( + sessionOptions as unknown as Parameters[0], + )) as SeaConnection; + } catch (err) { + throw decodeNapiKernelError(err); + } + + return new SeaSessionBackend({ + connection: nativeConnection!, + context: this.context, + id: nativeConnection!.sessionId, + }); } - // No-op so DBSQLClient.close() can finish its state-clearing block after a - // failed useSEA: true connect. Real teardown lands with the M1 SEA impl. - // eslint-disable-next-line @typescript-eslint/no-empty-function, class-methods-use-this - public async close(): Promise {} + public async close(): Promise { + // No backend-level resources to release — each `SeaSessionBackend` + // owns its own napi `Connection` lifecycle. + this.nativeOptions = undefined; + } } diff --git a/lib/sea/SeaErrorMapping.ts b/lib/sea/SeaErrorMapping.ts new file mode 100644 index 00000000..d7bec2ee --- /dev/null +++ b/lib/sea/SeaErrorMapping.ts @@ -0,0 +1,289 @@ +import HiveDriverError from '../errors/HiveDriverError'; +import AuthenticationError from '../errors/AuthenticationError'; +import OperationStateError, { OperationStateErrorCode } from '../errors/OperationStateError'; +import ParameterError from '../errors/ParameterError'; + +/** + * Sentinel prefix the napi binding's `napi_err_from_kernel` puts on + * `Error.message` when the underlying failure was a structured kernel + * `Error` rather than a plain napi `InvalidArg` from binding-side + * validation. Defined here (and in `native/sea/src/error.rs:44`) — the + * two MUST stay in lockstep. + */ +const ERROR_SENTINEL = '__databricks_error__:'; + +/** + * Shape of the kernel error surfaced by the napi-binding's `napi_err_from_kernel`. + * + * The Rust kernel's `kernel_error::Error` is exposed as a `JsError` whose + * properties mirror the Rust struct: the `ErrorCode` variant name (as a string), + * the message, and an optional SQLSTATE (either taken from the structured + * server response or recovered via `extract_sqlstate_from_message`). + */ +export interface KernelErrorShape { + /** Kernel `ErrorCode` variant name, e.g. `"Unauthenticated"`, `"SqlError"`. */ + code: string; + /** Human-readable error message. */ + message: string; + /** Optional SQLSTATE — five-char alphanumeric, when the kernel was able to surface it. */ + sqlstate?: string; +} + +/** + * Kernel `ErrorCode` variants — the 13 variants of the `#[non_exhaustive]` enum + * defined in `src/kernel_error.rs:66-134`. + * + * Kept here as a literal type rather than an `enum` so test exhaustiveness checks + * and runtime `code` strings are guaranteed to stay in lockstep with the kernel. + */ +export type KernelErrorCode = + | 'InvalidArgument' + | 'Unauthenticated' + | 'PermissionDenied' + | 'NotFound' + | 'ResourceExhausted' + | 'Unavailable' + | 'Timeout' + | 'Cancelled' + | 'DataLoss' + | 'Internal' + | 'InvalidStatementHandle' + | 'NetworkError' + | 'SqlError'; + +/** + * Optional metadata fields the kernel may attach via the + * `__databricks_error__:` envelope (per `native/sea/src/error.rs:50-89`). + * + * `errorCode` is namespaced under `kernelMetadata` rather than placed at + * the top level because `OperationStateError` already declares a top-level + * `errorCode: enum` field, and `DBSQLOperation.ts:209` switches on it + * (`err.errorCode === OperationStateErrorCode.Canceled`). Top-level + * defineProperty would clobber that enum with a kernel string and break + * cancel/close detection. + */ +export interface KernelMetadata { + errorCode?: string; + vendorCode?: number; + httpStatus?: number; + retryable?: boolean; + queryId?: string; +} + +/** + * An `Error` carrying optional SEA-side kernel context. `sqlState` is + * exposed at the top level (no collision in the existing driver error + * tree); the remaining envelope fields live under a `kernelMetadata` + * namespace to avoid clobbering pre-existing `errorCode` semantics on + * `OperationStateError`. + */ +export interface ErrorWithSqlState extends Error { + sqlState?: string; + kernelMetadata?: KernelMetadata; +} + +/** + * Attach a non-enumerable own-property to the error. The shape matches + * Node's convention for attaching `.code` to system errors: + * non-enumerable (clean `JSON.stringify`) but readable via direct + * property access and `Object.getOwnPropertyDescriptor`. One helper for + * both the top-level `sqlState` and the namespaced `kernelMetadata` + * object so the `defineProperty` flags live in exactly one place. + */ +function defineErrorMetadata(error: Error, key: K, value: V): void { + Object.defineProperty(error, key, { + value, + writable: true, + enumerable: false, + configurable: true, + }); +} + +/** + * Map a kernel error (as surfaced by the napi-binding) to the appropriate JS + * driver error class. + * + * M0 mapping table: + * Unauthenticated, PermissionDenied → AuthenticationError + * Cancelled → OperationStateError(Canceled) + * Timeout → OperationStateError(Timeout) + * InvalidArgument → ParameterError + * NetworkError, Unavailable, + * NotFound, ResourceExhausted, + * DataLoss, Internal, + * InvalidStatementHandle, SqlError → HiveDriverError + * + * Unknown `code` values (e.g. if the kernel adds a new variant) fall through + * to HiveDriverError so the driver never silently drops an error. The kernel's + * `ErrorCode` is `#[non_exhaustive]` so this can legitimately happen. + * + * SQLSTATE, when present, is attached on `error.sqlState` regardless of which + * class is returned. + */ +export function mapKernelErrorToJsError(kErr: KernelErrorShape): ErrorWithSqlState { + const { code, message, sqlstate } = kErr; + + let error: ErrorWithSqlState; + + switch (code as KernelErrorCode) { + case 'Unauthenticated': + case 'PermissionDenied': + error = new AuthenticationError(message); + break; + + case 'Cancelled': + // OperationStateError with the Canceled code carries the kernel message + // through the response.displayMessage fallback path. + error = new OperationStateError(OperationStateErrorCode.Canceled); + error.message = message; + break; + + case 'Timeout': + error = new OperationStateError(OperationStateErrorCode.Timeout); + error.message = message; + break; + + case 'InvalidArgument': + error = new ParameterError(message); + break; + + // All remaining kernel ErrorCode variants map to the base driver error class. + // M0 intentionally does not introduce new error classes; M1 may add nuance. + case 'NotFound': + case 'ResourceExhausted': + case 'Unavailable': + case 'DataLoss': + case 'Internal': + case 'InvalidStatementHandle': + case 'NetworkError': + case 'SqlError': + error = new HiveDriverError(message); + break; + + default: + // Unknown/future kernel variant — never drop the error, surface as base class. + error = new HiveDriverError(message); + break; + } + + if (sqlstate !== undefined) { + defineErrorMetadata(error, 'sqlState', sqlstate); + } + return error; +} + +/** + * Build a {@link KernelMetadata} object from a parsed envelope, applying + * per-field type validation. A kernel-side bug that emits, say, + * `retryable: "true"` (string) instead of `true` (boolean) would + * otherwise leak the wrong-typed value through to JS callers; the + * type-guard discards the malformed field rather than passing it through. + */ +function buildKernelMetadata(parsed: Record): KernelMetadata { + const meta: KernelMetadata = {}; + if (typeof parsed.errorCode === 'string') { + meta.errorCode = parsed.errorCode; + } + if (typeof parsed.vendorCode === 'number') { + meta.vendorCode = parsed.vendorCode; + } + if (typeof parsed.httpStatus === 'number') { + meta.httpStatus = parsed.httpStatus; + } + if (typeof parsed.retryable === 'boolean') { + meta.retryable = parsed.retryable; + } + if (typeof parsed.queryId === 'string') { + meta.queryId = parsed.queryId; + } + return meta; +} + +/** + * Decode a napi-binding error into the typed JS error class. + * + * Two paths: + * - Structured kernel error: `Error.message` starts with + * {@link ERROR_SENTINEL} followed by a JSON envelope. We strip the + * sentinel, parse the JSON, route the {@link KernelErrorShape} + * through {@link mapKernelErrorToJsError}, and attach the remaining + * envelope fields under a single non-enumerable `kernelMetadata` + * namespace. Namespacing avoids the collision with + * `OperationStateError.errorCode` (an enum already switched on at the + * JS layer — see `DBSQLOperation.ts:209`). + * - Binding-side error (e.g. `napi::Error::new(InvalidArg, "openSession: + * \`token\` is required for the requested auth mode")` produced by + * the binding's own validation): returned unchanged. These don't + * carry kernel `code` info, so we surface them as-is. + * + * Non-`Error` values (e.g. a `Promise.reject('string')`) pass through + * wrapped in `HiveDriverError` so callers always see an `Error` + * subclass. + */ +export function decodeNapiKernelError(err: unknown): Error { + if (!(err instanceof Error)) { + return new HiveDriverError(typeof err === 'string' ? err : 'SEA backend: unknown error'); + } + + const { message } = err; + if (typeof message !== 'string' || !message.startsWith(ERROR_SENTINEL)) { + return err; + } + + const jsonStr = message.slice(ERROR_SENTINEL.length); + let parsed: unknown; + try { + parsed = JSON.parse(jsonStr); + } catch { + // Corrupted envelope — surface the raw post-sentinel payload rather + // than silently dropping the original error. Strip the internal + // `__databricks_error__:` prefix; it's a binding/JS-side framing + // marker, not user-actionable, and leaking it makes the message + // confusing to operators triaging a malformed kernel response. + // + // Mutate in place when possible so the napi-binding's original + // stack survives — that stack is the only useful triage signal on + // a malformed-envelope path (where did a sentinel-prefixed + // non-JSON message come from?). Fall back to a fresh + // `HiveDriverError` only if a future napi-rs revision makes + // `Error.message` non-writable (no such guarantee today, but the + // descriptor contract is implementation-defined). + try { + err.message = jsonStr; + return err; + } catch { + return new HiveDriverError(jsonStr); + } + } + + if ( + typeof parsed !== 'object' || + parsed === null || + typeof (parsed as { code?: unknown }).code !== 'string' || + typeof (parsed as { message?: unknown }).message !== 'string' + ) { + return err; + } + + const envelope = parsed as Record; + const code = envelope.code as string; + const msg = envelope.message as string; + const sqlState = typeof envelope.sqlState === 'string' ? envelope.sqlState : undefined; + + const jsErr = mapKernelErrorToJsError({ code, message: msg, sqlstate: sqlState }); + + const meta = buildKernelMetadata(envelope); + // Skip the namespace attachment entirely when no fields validated + // through — keeps `err.kernelMetadata` absent rather than `{}` for + // simple envelopes (the common case). + if ( + meta.errorCode !== undefined || + meta.vendorCode !== undefined || + meta.httpStatus !== undefined || + meta.retryable !== undefined || + meta.queryId !== undefined + ) { + defineErrorMetadata(jsErr, 'kernelMetadata', meta); + } + return jsErr; +} diff --git a/lib/sea/SeaSessionBackend.ts b/lib/sea/SeaSessionBackend.ts new file mode 100644 index 00000000..6a1415f3 --- /dev/null +++ b/lib/sea/SeaSessionBackend.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { v4 as uuidv4 } from 'uuid'; +import ISessionBackend from '../contracts/ISessionBackend'; +import IOperationBackend from '../contracts/IOperationBackend'; +import IClientContext from '../contracts/IClientContext'; +import { + ExecuteStatementOptions, + TypeInfoRequest, + CatalogsRequest, + SchemasRequest, + TablesRequest, + TableTypesRequest, + ColumnsRequest, + FunctionsRequest, + PrimaryKeysRequest, + CrossReferenceRequest, +} from '../contracts/IDBSQLSession'; +import Status from '../dto/Status'; +import InfoValue from '../dto/InfoValue'; +import HiveDriverError from '../errors/HiveDriverError'; +import { SeaConnection } from './SeaNativeLoader'; +import { decodeNapiKernelError } from './SeaErrorMapping'; + +export interface SeaSessionBackendOptions { + /** The opaque napi `Connection` handle returned by `openSession`. */ + connection: SeaConnection; + context: IClientContext; + /** Optional override for `id`. Defaults to a fresh UUIDv4. */ + id?: string; +} + +/** + * SEA-backed implementation of `ISessionBackend`. + * + * **M0 scope:** `executeStatement` + `close`. Metadata methods + * (`getCatalogs`, `getSchemas`, etc.) defer to M1 — they throw a clear + * `HiveDriverError` so consumers using SEA against metadata APIs get an + * actionable message instead of silently falling back. The Thrift + * backend continues to handle the metadata path by default (callers + * opt into SEA via `ConnectionOptions.useSEA`). + * + * **Session config flow:** catalog / schema / sessionConf are applied + * once at session creation (kernel `Session::builder().defaults()` + + * `.session_conf()` → SEA `CreateSession.catalog` / `.schema` / + * `.session_confs`) and remain in effect for every statement run on + * the resulting napi `Connection`. No per-statement forwarding is + * needed — that pattern was removed when the napi binding moved these + * onto `openSession` to match pyo3. + */ +export default class SeaSessionBackend implements ISessionBackend { + private readonly connection: SeaConnection; + + private readonly context: IClientContext; + + private readonly _id: string; + + private closed = false; + + constructor({ connection, context, id }: SeaSessionBackendOptions) { + this.connection = connection; + this.context = context; + this._id = id ?? uuidv4(); + } + + public get id(): string { + return this._id; + } + + public async getInfo(_infoType: number): Promise { + throw new HiveDriverError('SeaSessionBackend.getInfo: not implemented yet (deferred to M1)'); + } + + /** + * Execute a SQL statement through the napi binding. + * + * Catalog / schema / sessionConf were applied at session open, so + * there are no per-statement options to thread through. + * + * M0 intentionally rejects `queryTimeout`, `namedParameters`, and + * `ordinalParameters` with explicit deferred-to-M1 errors. `useCloudFetch` + * is a no-op on the SEA path — the kernel hardcodes the SEA + * `disposition` to `INLINE_OR_EXTERNAL_LINKS`, and per-statement + * conf overrides have no reader on the kernel; cloud-fetch behaviour + * is governed entirely by the kernel's `ResultConfig` (M1 binding + * surface). + * + * The Thrift backend remains the path for consumers that need any + * of those today. + */ + // The result-execution path (napi `Connection.executeStatement` → result + // pipeline) is wired in the SEA execution feature. Until then SEA + // executeStatement throws a clear, actionable error rather than silently + // failing, so a `useSEA: true` caller knows the path is not yet available. + public async executeStatement(_statement: string, _options: ExecuteStatementOptions): Promise { + this.failIfClosed(); + throw new HiveDriverError( + 'SeaSessionBackend.executeStatement: not implemented yet (wired in the SEA execution feature)', + ); + } + + public async getTypeInfo(_request: TypeInfoRequest): Promise { + throw new HiveDriverError('SeaSessionBackend.getTypeInfo: not implemented yet (deferred to M1)'); + } + + public async getCatalogs(_request: CatalogsRequest): Promise { + throw new HiveDriverError('SeaSessionBackend.getCatalogs: not implemented yet (deferred to M1)'); + } + + public async getSchemas(_request: SchemasRequest): Promise { + throw new HiveDriverError('SeaSessionBackend.getSchemas: not implemented yet (deferred to M1)'); + } + + public async getTables(_request: TablesRequest): Promise { + throw new HiveDriverError('SeaSessionBackend.getTables: not implemented yet (deferred to M1)'); + } + + public async getTableTypes(_request: TableTypesRequest): Promise { + throw new HiveDriverError('SeaSessionBackend.getTableTypes: not implemented yet (deferred to M1)'); + } + + public async getColumns(_request: ColumnsRequest): Promise { + throw new HiveDriverError('SeaSessionBackend.getColumns: not implemented yet (deferred to M1)'); + } + + public async getFunctions(_request: FunctionsRequest): Promise { + throw new HiveDriverError('SeaSessionBackend.getFunctions: not implemented yet (deferred to M1)'); + } + + public async getPrimaryKeys(_request: PrimaryKeysRequest): Promise { + throw new HiveDriverError('SeaSessionBackend.getPrimaryKeys: not implemented yet (deferred to M1)'); + } + + public async getCrossReference(_request: CrossReferenceRequest): Promise { + throw new HiveDriverError('SeaSessionBackend.getCrossReference: not implemented yet (deferred to M1)'); + } + + public async close(): Promise { + if (this.closed) { + return Status.success(); + } + try { + await this.connection.close(); + } catch (err) { + throw decodeNapiKernelError(err); + } + this.closed = true; + return Status.success(); + } + + private failIfClosed(): void { + if (this.closed) { + throw new HiveDriverError('SeaSessionBackend: session is closed'); + } + } +} diff --git a/lib/utils/prependSlash.ts b/lib/utils/prependSlash.ts new file mode 100644 index 00000000..a3ed7d92 --- /dev/null +++ b/lib/utils/prependSlash.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Normalise an HTTP path to a leading-slash form. Empty strings are left + * untouched. Shared by the Thrift connect path (`DBSQLClient`) and the + * SEA auth adapter (`SeaAuth`) so the two can't drift. + */ +export default function prependSlash(str: string): string { + if (str.length > 0 && str.charAt(0) !== '/') { + return `/${str}`; + } + return str; +} diff --git a/native/sea/index.d.ts b/native/sea/index.d.ts index eb16e8ac..807b8a51 100644 --- a/native/sea/index.d.ts +++ b/native/sea/index.d.ts @@ -4,8 +4,105 @@ /* auto-generated by NAPI-RS */ /** - * JS-visible options for opening a Databricks SQL session over PAT. - * `token` is required. + * Per-statement options for `Connection.executeStatement`. + * + * Mirrors the kernel `StatementSpec` knobs that are safe to thread + * through napi without a kernel-side change. Today this covers: + * - `statementConf` — per-statement Spark conf overlay + * (`StatementSpec.statement_conf` → SEA `parameters` / + * Thrift `confOverlay`) + * - `queryTags` — convenience wrapper over `statementConf` with + * key `query_tags`; serialised to the same comma-separated + * `key:value` wire shape NodeJS Thrift's `serializeQueryTags` + * produces (`lib/utils/queryTags.ts`). Backslashes in keys are + * doubled; backslash/colon/comma in values are backslash-escaped. + * + * `rowLimit` (SEA `row_limit`) and `queryTimeoutSecs` (the per-statement + * server wait timeout) are exposed here and threaded onto the kernel + * `StatementSpec`. Positional and named parameters remain deferred: they + * require a non-trivial JS↔napi `TypedValue` mapping and land in a + * follow-on PR. + * + * **Tag-order caveat (M4 parity note).** The napi `queryTags` field + * is a Rust `HashMap` whose iteration order is + * non-deterministic, so the serialised `query_tags` value may have + * a different key order than Thrift's `serializeQueryTags` (which + * iterates `Object.keys(...)` in insertion order) for the same + * input. The SEA server is order-insensitive on conf values, so + * the two are functionally equivalent. If a caller needs + * byte-identical Thrift parity, the JS adapter pre-serialises via + * `serializeQueryTags` and writes the result into + * `statementConf["query_tags"]` directly — see + * `SeaSessionBackend.executeStatement` in the NodeJS driver. This + * path is the one the production code uses. + */ +export interface ExecuteOptions { + /** + * Per-statement Spark conf overlay. Merged on top of the + * session-level `sessionConf` at execute time; this map wins + * on key collisions. Unknown keys are rejected by the server. + */ + statementConf?: Record + /** + * Query tags as key→value pairs. Serialised to a comma- + * separated `key:value` string (backslash-escaping `\`, `:`, + * `,`) and placed into `statementConf["query_tags"]`, matching + * NodeJS Thrift's `serializeQueryTags` wire shape. Passing + * both `queryTags` AND a `query_tags` key in `statementConf` + * raises `InvalidArgument` — the caller's intent is ambiguous + * so we refuse to silently pick one over the other. + * + * See the struct-level "Tag-order caveat" for the + * HashMap-iteration-order vs `Object.keys`-iteration-order + * divergence and the byte-identical-Thrift-parity workaround. + */ + queryTags?: Record + /** + * Server-side cap on the number of rows this statement returns + * (SEA `row_limit`), independent of any SQL `LIMIT`. Maps to + * `StatementSpec.row_limit`. Omitted ⇒ no driver-imposed cap. + */ + rowLimit?: number + /** + * Per-statement server wait timeout in whole seconds. Bounds how + * long the server waits before cancelling the statement + * (`on_wait_timeout = CANCEL`), surfacing as a timeout — the + * server statement timeout (JDBC `setQueryTimeout`). Maps to + * `StatementSpec.query_timeout_secs`. Distinct from the + * connection-level transport timeout. The SEA wire caps it at 50s. + */ + queryTimeoutSecs?: number +} +/** + * Authentication mode selector crossing the napi boundary. The string + * literals are what napi-rs emits from this `#[napi(string_enum)]` — the + * NodeJS SEA adapter (`SeaAuth`) matches them verbatim (`'Pat'`, + * `'OAuthM2m'`, `'OAuthU2m'`). + * + * Mirrors the kernel [`AuthConfig`] variants this binding supports. + * `OAuthFederation` / `External` are intentionally not exposed yet — the + * kernel marks federation as not-yet-implemented and `External` is a + * Rust-trait escape hatch with no JS-callback bridge. + */ +export const enum AuthMode { + /** Personal access token (`token`). */ + Pat = 'Pat', + /** OAuth 2.0 machine-to-machine — `oauthClientId` + `oauthClientSecret`. */ + OAuthM2m = 'OAuthM2m', + /** + * OAuth 2.0 user-to-machine (browser flow) — optional `oauthClientId` + * + `oauthRedirectPort`. + */ + OAuthU2m = 'OAuthU2m' +} +/** + * JS-visible options for opening a Databricks SQL session. + * + * Authentication is selected by `authMode` (default [`AuthMode::Pat`]): + * - `Pat` — `token` required. + * - `OAuthM2m` — `oauthClientId` + `oauthClientSecret` required. + * - `OAuthU2m` — `oauthClientId` / `oauthRedirectPort` optional (kernel + * defaults to the `databricks-cli` client on port 8020). * * Catalog / schema / sessionConf are applied once at session creation * and remain in effect for every statement run on the resulting @@ -25,10 +122,32 @@ export interface ConnectionOptions { */ httpPath: string /** - * Personal access token. Must be non-empty (the kernel rejects - * empty PATs at session construction). + * Authentication mode. Omitted ⇒ [`AuthMode::Pat`] (back-compat: + * existing PAT callers pass only `token`). + */ + authMode?: AuthMode + /** + * Personal access token. Required (and non-empty) for + * [`AuthMode::Pat`]; ignored otherwise. */ - token: string + token?: string + /** + * OAuth client id. Required for [`AuthMode::OAuthM2m`]; optional for + * [`AuthMode::OAuthU2m`] (kernel defaults to `databricks-cli`). + */ + oauthClientId?: string + /** OAuth client secret. Required for [`AuthMode::OAuthM2m`]. */ + oauthClientSecret?: string + /** + * Localhost callback port for the [`AuthMode::OAuthU2m`] browser + * flow. Omitted ⇒ kernel default (8020). + */ + oauthRedirectPort?: number + /** + * OAuth scopes override (M2M / U2M). Omitted ⇒ kernel defaults + * (`["all-apis"]` for M2M; `["all-apis", "offline_access"]` for U2M). + */ + oauthScopes?: Array /** * Default catalog for statements executed on this session. * Routed through the kernel's `DefaultOpts` and onto the SEA @@ -72,8 +191,10 @@ export interface ConnectionOptions { maxConnections?: number } /** - * Open a Databricks SQL session over PAT auth and return an opaque - * `Connection` wrapping the kernel `Session`. + * Open a Databricks SQL session and return an opaque `Connection` + * wrapping the kernel `Session`. Authentication is selected by + * `options.auth_mode` (PAT / OAuth M2M / OAuth U2M) — see + * [`build_auth_config`]. * * The JS-visible name is `openSession` (napi-rs converts snake_case * to camelCase for free functions). @@ -141,10 +262,18 @@ export declare class Connection { * Execute a SQL statement and return a Statement handle that * streams batches via `fetchNextBatch()`. * - * No per-statement options: catalog / schema / sessionConf are - * session-level (`openSession`). + * Catalog / schema / sessionConf are session-level + * (`openSession`). Per-statement options on `ExecuteOptions`: + * - `statementConf` — per-statement Spark conf overlay + * - `queryTags` — serialised to a comma-separated `key:value` + * string and placed in `statement_conf["query_tags"]`, + * matching NodeJS Thrift's `serializeQueryTags` wire shape + * + * `options` is omitted/`None` for the no-options path; passing + * `{ statementConf: {} }` (an empty map) is treated the same as + * omission to keep the wire shape stable for the common case. */ - executeStatement(sql: string): Promise + executeStatement(sql: string, options?: ExecuteOptions | undefined | null): Promise /** * Explicit close. Awaits the server-side `DeleteSession` so the * JS caller can observe failures (auth revoked mid-session, @@ -188,51 +317,6 @@ export declare class Statement { * kernel / server logs which key on the same id. */ get statementId(): string - /** - * Number of rows modified by the statement (UPDATE / INSERT / - * DELETE / MERGE). `null` for SELECT and on warehouses that don't - * surface the counter. Mirrors Thrift's - * `TGetOperationStatusResp.numModifiedRows`. - */ - numModifiedRows(): Promise - /** - * Server-supplied user-facing message. Mirrors Thrift's - * `TGetOperationStatusResp.displayMessage`. **PII / sensitive- - * data note:** may contain SQL fragments or parameter values — - * redact before centralised logging. - * - * Populated on `Succeeded` / `Closed-with-inline-data` paths. - * On terminal-error states (`Failed` / `Cancelled` / - * `Closed-no-data`) the kernel returns an Error instead of a - * `Statement`, and the same field rides on the JS Error envelope - * under the same `displayMessage` key. - */ - displayMessage(): Promise - /** - * Server-supplied diagnostic detail — multi-line operator / - * stack context. Mirrors Thrift's - * `TGetOperationStatusResp.diagnosticInfo`. For support surfaces, - * not user-facing. Same reachability + PII caveats as - * `displayMessage`. - */ - diagnosticInfo(): Promise - /** - * Server-supplied JSON blob with extended error details. Mirrors - * Thrift's `TGetOperationStatusResp.errorDetailsJson`. - * Pass-through string — JS callers parse with `JSON.parse` if - * they need structured access. - * - * **Server-side gating:** populated only when the workspace has - * `spark.databricks.sql.errorDetailsJson.enabled = true` on the - * underlying SQL cluster. The flag is internal-only / default- - * false in the Databricks runtime, so for most JS callers this - * will return `null`. Admin-enabled workspaces return content - * shaped like `{"errorClass": "...", "messageTemplate": "..."}`. - * - * **Unbounded:** when populated, server can return a multi-MB - * blob; size before logging. - */ - errorDetailsJson(): Promise /** * Pull the next batch of results. Returns `null` when the stream * is exhausted. The returned `ArrowBatch.ipcBytes` is a complete diff --git a/native/sea/index.js b/native/sea/index.js index 6153729d..5ce7146d 100644 --- a/native/sea/index.js +++ b/native/sea/index.js @@ -37,7 +37,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.android-arm64.node') } else { - nativeBinding = require('@databricks/sql-kernel-android-arm64') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-android-arm64') } } catch (e) { loadError = e @@ -49,7 +49,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.android-arm-eabi.node') } else { - nativeBinding = require('@databricks/sql-kernel-android-arm-eabi') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-android-arm-eabi') } } catch (e) { loadError = e @@ -69,7 +69,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.win32-x64-msvc.node') } else { - nativeBinding = require('@databricks/sql-kernel-win32-x64-msvc') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-win32-x64-msvc') } } catch (e) { loadError = e @@ -83,7 +83,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.win32-ia32-msvc.node') } else { - nativeBinding = require('@databricks/sql-kernel-win32-ia32-msvc') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-win32-ia32-msvc') } } catch (e) { loadError = e @@ -97,7 +97,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.win32-arm64-msvc.node') } else { - nativeBinding = require('@databricks/sql-kernel-win32-arm64-msvc') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-win32-arm64-msvc') } } catch (e) { loadError = e @@ -113,7 +113,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.darwin-universal.node') } else { - nativeBinding = require('@databricks/sql-kernel-darwin-universal') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-darwin-universal') } break } catch {} @@ -124,7 +124,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.darwin-x64.node') } else { - nativeBinding = require('@databricks/sql-kernel-darwin-x64') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-darwin-x64') } } catch (e) { loadError = e @@ -138,7 +138,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.darwin-arm64.node') } else { - nativeBinding = require('@databricks/sql-kernel-darwin-arm64') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-darwin-arm64') } } catch (e) { loadError = e @@ -157,7 +157,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.freebsd-x64.node') } else { - nativeBinding = require('@databricks/sql-kernel-freebsd-x64') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-freebsd-x64') } } catch (e) { loadError = e @@ -174,7 +174,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-x64-musl.node') } else { - nativeBinding = require('@databricks/sql-kernel-linux-x64-musl') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-x64-musl') } } catch (e) { loadError = e @@ -187,7 +187,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-x64-gnu.node') } else { - nativeBinding = require('@databricks/sql-kernel-linux-x64-gnu') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-x64-gnu') } } catch (e) { loadError = e @@ -203,7 +203,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-arm64-musl.node') } else { - nativeBinding = require('@databricks/sql-kernel-linux-arm64-musl') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-arm64-musl') } } catch (e) { loadError = e @@ -216,7 +216,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-arm64-gnu.node') } else { - nativeBinding = require('@databricks/sql-kernel-linux-arm64-gnu') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-arm64-gnu') } } catch (e) { loadError = e @@ -232,7 +232,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-arm-musleabihf.node') } else { - nativeBinding = require('@databricks/sql-kernel-linux-arm-musleabihf') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-arm-musleabihf') } } catch (e) { loadError = e @@ -245,7 +245,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-arm-gnueabihf.node') } else { - nativeBinding = require('@databricks/sql-kernel-linux-arm-gnueabihf') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-arm-gnueabihf') } } catch (e) { loadError = e @@ -261,7 +261,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-riscv64-musl.node') } else { - nativeBinding = require('@databricks/sql-kernel-linux-riscv64-musl') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-riscv64-musl') } } catch (e) { loadError = e @@ -274,7 +274,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-riscv64-gnu.node') } else { - nativeBinding = require('@databricks/sql-kernel-linux-riscv64-gnu') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-riscv64-gnu') } } catch (e) { loadError = e @@ -289,7 +289,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-s390x-gnu.node') } else { - nativeBinding = require('@databricks/sql-kernel-linux-s390x-gnu') + nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-s390x-gnu') } } catch (e) { loadError = e @@ -310,9 +310,10 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { Connection, openSession, Statement, version } = nativeBinding +const { Connection, AuthMode, openSession, Statement, version } = nativeBinding module.exports.Connection = Connection +module.exports.AuthMode = AuthMode module.exports.openSession = openSession module.exports.Statement = Statement module.exports.version = version diff --git a/tests/e2e/sea/auth-m2m-e2e.test.ts b/tests/e2e/sea/auth-m2m-e2e.test.ts new file mode 100644 index 00000000..debd74a7 --- /dev/null +++ b/tests/e2e/sea/auth-m2m-e2e.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { DBSQLClient } from '../../../lib'; +import AuthenticationError from '../../../lib/errors/AuthenticationError'; +import { isBlankOrReserved } from '../../../lib/sea/SeaAuth'; +import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; +import { InternalConnectionOptions } from '../../../lib/contracts/InternalConnectionOptions'; + +/** + * sea-auth M1 OAuth M2M end-to-end: + * 1. Construct a DBSQLClient. + * 2. `connect({ useSEA: true, authType: 'databricks-oauth', oauthClientId, + * oauthClientSecret })` against pecotesting. + * 3. `openSession()` — kernel runs OIDC discovery + client_credentials + * exchange. Successful openSession is the proof that the kernel-side + * OAuth M2M plumbing works end-to-end: discovery + token exchange + + * Bearer header on the create-session request all succeeded. + * 4. Close the session, then the client. + * + * No query is executed here — execution is the responsibility of the + * sea-execution feature's own e2e (mirror of the M0 PAT e2e scope at + * `auth-pat-e2e.test.ts`). If kernel-side OAuth fails, `openSession()` + * raises before returning. + * + * Required env (exported by `/home/madhavendra.rathore/.zshrc` on the developer machine): + * - DATABRICKS_PECOTESTING_SERVER_HOSTNAME + * - DATABRICKS_PECOTESTING_HTTP_PATH2 (second pecotesting warehouse — AAD SP registered here) + * - DATABRICKS_PECOTESTING_AAD_CLIENT_ID (Azure AD SP registered on pecotesting) + * - DATABRICKS_PECOTESTING_AAD_CLIENT_SECRET (matching secret) + * + * Skipped (not failed) when any of the four env vars is missing, so CI + * machines without OAuth credentials don't fail-flap. + */ +describe('sea-auth e2e — OAuth M2M through DBSQLClient ↔ SeaBackend ↔ napi binding', function suite() { + const host = process.env.DATABRICKS_PECOTESTING_SERVER_HOSTNAME; + const path = process.env.DATABRICKS_PECOTESTING_HTTP_PATH2; + const oauthClientId = process.env.DATABRICKS_PECOTESTING_AAD_CLIENT_ID; + const oauthClientSecret = process.env.DATABRICKS_PECOTESTING_AAD_CLIENT_SECRET; + + this.timeout(120_000); + + before(function gate() { + // Reject not just absent env vars but also blank/whitespace/literal- + // `'undefined'`/`'null'` values from buggy shell exports — these + // would otherwise reach the workspace as bogus creds and yield an + // `invalid_client` indistinguishable from a real SP-not-registered + // issue. Reuse the production `isBlankOrReserved` predicate so the + // test gate stays in lockstep with the case-insensitive variant + // shipped in round-2 (B-3 fix). + const looksReal = (s: string | undefined): s is string => typeof s === 'string' && !isBlankOrReserved(s); + if (!looksReal(host) || !looksReal(path) || !looksReal(oauthClientId) || !looksReal(oauthClientSecret)) { + // eslint-disable-next-line no-invalid-this + this.skip(); + } + }); + + it('connects, opens a session, closes the session, closes the client', async () => { + const client = new DBSQLClient(); + + const connected = await client.connect({ + host: host as string, + path: path as string, + authType: 'databricks-oauth', + oauthClientId: oauthClientId as string, + oauthClientSecret: oauthClientSecret as string, + useSEA: true, + } as ConnectionOptions & InternalConnectionOptions); + expect(connected).to.equal(client); + + const session = await client.openSession(); + expect(session.id).to.be.a('string'); + expect(session.id.length).to.be.greaterThan(0); + + const status = await session.close(); + expect(status.isSuccess).to.equal(true); + + await client.close(); + }); + + // Negative path — proves the kernel-side OAuth error path is intact + // and surfaces as the typed `AuthenticationError` (DA-F1 + DA-F6). + // Distinguishes "creds wrong" (this test passes with bogus secret) + // from "all code broken" (this test fails with a non-AuthenticationError). + it('rejects with AuthenticationError when oauthClientSecret is deliberately wrong', async () => { + const client = new DBSQLClient(); + + await client.connect({ + host: host as string, + path: path as string, + authType: 'databricks-oauth', + oauthClientId: oauthClientId as string, + oauthClientSecret: 'definitely-not-the-real-secret-deadbeef', + useSEA: true, + } as ConnectionOptions & InternalConnectionOptions); + + let caught: unknown; + try { + await client.openSession(); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(AuthenticationError); + expect((caught as Error).message).to.match(/invalid_client/i); + + await client.close(); + }); +}); diff --git a/tests/e2e/sea/auth-pat-e2e.test.ts b/tests/e2e/sea/auth-pat-e2e.test.ts new file mode 100644 index 00000000..d061d5b2 --- /dev/null +++ b/tests/e2e/sea/auth-pat-e2e.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { DBSQLClient } from '../../../lib'; +import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; +import { InternalConnectionOptions } from '../../../lib/contracts/InternalConnectionOptions'; + +/** + * sea-auth M0 end-to-end: + * 1. Construct a DBSQLClient. + * 2. `connect({ useSEA: true, token })` against pecotesting. + * 3. `openSession()` — round-trips through the napi binding. + * 4. Close the session, then the client. + * + * No query is executed here — execution is the responsibility of the + * sea-execution feature's own e2e. This test exists solely to confirm + * the PAT round-trips end-to-end and the napi binding's `openSession` + * surface is reachable from `DBSQLClient`. + * + * Required env (exported by `~/.zshrc` on the developer machine): + * - DATABRICKS_PECOTESTING_SERVER_HOSTNAME + * - DATABRICKS_PECOTESTING_HTTP_PATH + * - DATABRICKS_PECOTESTING_TOKEN_PERSONAL (preferred — personal PAT) + * - DATABRICKS_PECOTESTING_TOKEN (fallback — shared PAT) + * + * If any of the three required env vars is missing, the suite is skipped + * so CI machines without secrets don't fail-flap. + */ +describe('sea-auth e2e — PAT through DBSQLClient ↔ SeaBackend ↔ napi binding', function suite() { + const host = process.env.DATABRICKS_PECOTESTING_SERVER_HOSTNAME; + const path = process.env.DATABRICKS_PECOTESTING_HTTP_PATH; + const token = process.env.DATABRICKS_PECOTESTING_TOKEN_PERSONAL || process.env.DATABRICKS_PECOTESTING_TOKEN; + + this.timeout(120_000); + + before(function gate() { + if (!host || !path || !token) { + // eslint-disable-next-line no-invalid-this + this.skip(); + } + }); + + it('connects, opens a session, closes the session, closes the client', async () => { + const client = new DBSQLClient(); + + const connected = await client.connect({ + host: host as string, + path: path as string, + token: token as string, + // `useSEA` is an internal opt-in (InternalConnectionOptions), not a + // public ConnectionOptions field — cast exactly as DBSQLClient.connect + // does internally so the literal passes excess-property checking. + useSEA: true, + } as ConnectionOptions & InternalConnectionOptions); + expect(connected).to.equal(client); + + const session = await client.openSession(); + expect(session).to.exist; + expect(session.id).to.be.a('string'); + expect(session.id.length).to.be.greaterThan(0); + + const status = await session.close(); + expect(status.isSuccess).to.equal(true); + + await client.close(); + }); +}); diff --git a/tests/e2e/sea/auth-u2m-e2e.test.ts b/tests/e2e/sea/auth-u2m-e2e.test.ts new file mode 100644 index 00000000..923d5f0e --- /dev/null +++ b/tests/e2e/sea/auth-u2m-e2e.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { DBSQLClient } from '../../../lib'; +import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; +import { InternalConnectionOptions } from '../../../lib/contracts/InternalConnectionOptions'; + +/** + * sea-auth M1 OAuth U2M end-to-end — **SKIPPED pending browser harness**. + * + * U2M is interactive: the kernel opens a system browser + * (`auth/oauth/u2m.rs:414`, via the `open` crate), binds a local + * listener on port 8030 (via the JS adapter's hardcoded override), and + * waits up to 120s for the user to authenticate. + * + * Driving this from CI requires Playwright/Puppeteer to navigate the + * browser through the workspace login + consent screens. That harness + * is tracked as `TBD-oauth_u2m_test_harness` in testing-agent's + * findings; until it exists, this test stays `it.skip` so the e2e + * suite carries a slot for whoever lands the harness work. + * + * The intended assertion sequence (mirrors `auth-m2m-e2e.test.ts`): + * 1. `client.connect({ useSEA: true, authType: 'databricks-oauth' })` + * — NO `oauthClientSecret` → kernel picks the U2M flow. + * 2. `openSession()` — kernel opens browser, waits for callback on + * localhost:8030, exchanges the auth code, returns Bearer token, + * issues the create-session request to SEA. + * 3. `session.close()` then `client.close()`. + * + * Required env (gated additionally via `it.skip` until the harness + * lands, so absent env is a no-op today): + * - DATABRICKS_PECOTESTING_SERVER_HOSTNAME + * - DATABRICKS_PECOTESTING_HTTP_PATH + * - (no client_id/secret — U2M uses kernel default `databricks-cli`) + */ +describe('sea-auth e2e — OAuth U2M through DBSQLClient ↔ SeaBackend ↔ napi binding', function suite() { + this.timeout(300_000); + + it.skip('[pending TBD-oauth_u2m_test_harness] interactive U2M round-trip', async () => { + const host = process.env.DATABRICKS_PECOTESTING_SERVER_HOSTNAME as string; + const path = process.env.DATABRICKS_PECOTESTING_HTTP_PATH as string; + + const client = new DBSQLClient(); + + const connected = await client.connect({ + host, + path, + authType: 'databricks-oauth', + useSEA: true, + } as ConnectionOptions & InternalConnectionOptions); + expect(connected).to.equal(client); + + const session = await client.openSession(); + expect(session.id).to.be.a('string'); + + const status = await session.close(); + expect(status.isSuccess).to.equal(true); + + await client.close(); + }); +}); diff --git a/tests/unit/DBSQLClient.test.ts b/tests/unit/DBSQLClient.test.ts index 5054db4d..81d41f2e 100644 --- a/tests/unit/DBSQLClient.test.ts +++ b/tests/unit/DBSQLClient.test.ts @@ -122,21 +122,26 @@ describe('DBSQLClient.connect', () => { const client = new DBSQLClient(); // `useSEA` is on a non-exported InternalConnectionOptions; cast through any. - const seaOptions = { ...connectOptions, useSEA: true } as any; + // An empty token makes the real SeaBackend reject during connect() (auth + // validation); where the native binding is absent (e.g. CI, which does not + // build it) construction throws even earlier. Either way connect() must + // reject, so we can assert the partial-init guard leaves `backend` unset. + const seaOptions = { ...connectOptions, token: '', useSEA: true } as any; try { await client.connect(seaOptions); - expect.fail('SeaBackend.connect should throw until M1 wires the binding'); + expect.fail('SeaBackend connect() should reject (empty PAT / absent native binding)'); } catch (error) { if (error instanceof AssertionError || !(error instanceof Error)) { throw error; } - expect(error.message).to.match(/not implemented/); + // The exact message differs by environment (auth rejection vs binding-load + // failure); the contract under test is simply that connect() rejected. } // The partial-init guard (L2 fix) means backend stays undefined after a // failed connect, so the next openSession surfaces "not connected" rather - // than the SeaBackend's "not implemented" error. + // than the SeaBackend's own connect/auth error. expect((client as any).backend).to.equal(undefined); try { diff --git a/tests/unit/sea/SeaBackend.test.ts b/tests/unit/sea/SeaBackend.test.ts deleted file mode 100644 index ff9e45c9..00000000 --- a/tests/unit/sea/SeaBackend.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect, AssertionError } from 'chai'; -import SeaBackend from '../../../lib/sea/SeaBackend'; -import HiveDriverError from '../../../lib/errors/HiveDriverError'; -import { ConnectionOptions, OpenSessionRequest } from '../../../lib/contracts/IDBSQLClient'; - -describe('SeaBackend stub', () => { - it('connect() rejects with HiveDriverError until M1 wires the binding', async () => { - const backend = new SeaBackend(); - try { - await backend.connect({ host: '', path: '', token: '' } as ConnectionOptions); - expect.fail('It should throw an error'); - } catch (error) { - if (error instanceof AssertionError || !(error instanceof Error)) { - throw error; - } - expect(error).to.be.instanceOf(HiveDriverError); - expect(error.message).to.contain('not implemented'); - } - }); - - it('openSession() rejects with HiveDriverError until M1 wires the binding', async () => { - const backend = new SeaBackend(); - try { - await backend.openSession({} as OpenSessionRequest); - expect.fail('It should throw an error'); - } catch (error) { - if (error instanceof AssertionError || !(error instanceof Error)) { - throw error; - } - expect(error).to.be.instanceOf(HiveDriverError); - expect(error.message).to.contain('not implemented'); - } - }); - - it('close() is a no-op so DBSQLClient.close() can finish state-clearing after a failed connect', async () => { - const backend = new SeaBackend(); - await backend.close(); - }); -}); diff --git a/tests/unit/sea/_helpers/fakeBinding.ts b/tests/unit/sea/_helpers/fakeBinding.ts new file mode 100644 index 00000000..bffaad9f --- /dev/null +++ b/tests/unit/sea/_helpers/fakeBinding.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SeaNativeBinding, SeaConnection } from '../../../../lib/sea/SeaNativeLoader'; + +export interface RecordedCall { + method: string; + args: unknown[]; +} + +export interface FakeBinding { + binding: SeaNativeBinding; + calls: RecordedCall[]; +} + +/** + * Build a fake `SeaNativeBinding` that records every `openSession` call + * and returns a `Connection` whose `close()` is also recorded. Shared + * across the SEA auth unit-test files (PAT / M2M / U2M / future flows) + * so the closure shape lives in exactly one place. + * + * No real native code runs — the fake is structural-typing-only. + */ +export function makeFakeBinding(): FakeBinding { + const calls: RecordedCall[] = []; + + const fakeConnection = { + async executeStatement() { + throw new Error('not used in this test'); + }, + async close() { + calls.push({ method: 'connection.close', args: [] }); + }, + }; + + // Cast the whole fake through `unknown`: the real binding type carries an + // `AuthMode` const enum (and may gain more members), which can't be + // fabricated as a runtime value, so a structural cast is the pragmatic seam. + const binding = { + version() { + return 'fake-binding'; + }, + async openSession(opts: Parameters[0]) { + calls.push({ method: 'openSession', args: [opts] }); + return fakeConnection as unknown as SeaConnection; + }, + Connection: function FakeConnection() {}, + Statement: function FakeStatement() {}, + } as unknown as SeaNativeBinding; + + return { binding, calls }; +} diff --git a/tests/unit/sea/auth-edge-cases.test.ts b/tests/unit/sea/auth-edge-cases.test.ts new file mode 100644 index 00000000..72f40333 --- /dev/null +++ b/tests/unit/sea/auth-edge-cases.test.ts @@ -0,0 +1,614 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import SeaBackend from '../../../lib/sea/SeaBackend'; +import { buildSeaConnectionOptions } from '../../../lib/sea/SeaAuth'; +import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; +import AuthenticationError from '../../../lib/errors/AuthenticationError'; +import HiveDriverError from '../../../lib/errors/HiveDriverError'; +import { makeFakeBinding } from './_helpers/fakeBinding'; + +describe('SeaAuth — edge cases (input validation + ambiguity)', () => { + describe('whitespace-only and reserved-literal credentials are rejected', () => { + it('rejects whitespace-only PAT', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: ' \t ', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(AuthenticationError, /non-empty PAT/); + }); + + it('rejects literal "undefined" as PAT (buggy shell-export hazard)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: 'undefined', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(AuthenticationError, /non-empty PAT/); + }); + + it('rejects literal "null" as PAT', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: 'null', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(AuthenticationError, /non-empty PAT/); + }); + + it('rejects mixed-case "UNDEFINED" / "Null" / "NULL" as PAT (case-insensitive)', () => { + for (const reserved of ['UNDEFINED', 'Undefined', 'Null', 'NULL', 'nUlL']) { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: reserved, + }; + + expect(() => buildSeaConnectionOptions(opts), `for token=${reserved}`).to.throw( + AuthenticationError, + /non-empty PAT/, + ); + } + }); + + // Round-4 NF3-2: presence of `oauthClientId` signals M2M intent. + // A blank/reserved-literal `oauthClientSecret` is then a missing-secret + // typo, not a request to fall back to U2M. Surface the M2M "secret + // required" AuthenticationError so the user fixes the real problem + // rather than swap class to a HiveDriverError pointing at a flow + // they didn't intend to use. + it('rejects mixed-case reserved-literal oauthClientSecret with AuthenticationError when id is set', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'NULL', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + AuthenticationError, + /oauthClientSecret.*non-empty.*OAuth M2M/, + ); + }); + + it('rejects whitespace-only oauthClientId on M2M', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: ' ', + oauthClientSecret: 'dose-fake-secret', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(AuthenticationError, /oauthClientId.*required/); + }); + + it('rejects whitespace-only oauthClientSecret with AuthenticationError when oauthClientId is set (M2M intent)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: '\n\t', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + AuthenticationError, + /oauthClientSecret.*non-empty.*OAuth M2M/, + ); + }); + + it('rejects literal "undefined" as oauthClientId on M2M', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'undefined', + oauthClientSecret: 'dose-fake-secret', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(AuthenticationError, /oauthClientId.*required/); + }); + + it('rejects literal "undefined" as oauthClientSecret with AuthenticationError when id is set (M2M intent)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'undefined', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + AuthenticationError, + /oauthClientSecret.*non-empty.*OAuth M2M/, + ); + }); + + // Round-4 NF3-2: pin the exact class against the round-3 NF-N3 + // regression where M2M-with-empty-secret was routed through the U2M + // arm and raised a bare `HiveDriverError`. `instanceof + // AuthenticationError` correctly returns `false` for a bare + // `HiveDriverError` instance (instanceof is a one-way subclass + // check), so the subclass check IS sufficient to catch the + // regression. We don't add an `error.name` or `constructor.name` + // belt — the former requires `this.name` on the subclass (LE4-1 + // handles that separately for downstream-consumer benefit, not for + // this test), and the latter is bundler-fragile (terser/esbuild + // strip class names without `keep_classnames`). + it('M2M-with-empty-secret throws AuthenticationError, not bare HiveDriverError (class pin)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'x', + oauthClientSecret: '', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + AuthenticationError, + /oauthClientSecret.*non-empty.*OAuth M2M/, + ); + }); + + // Round-5 DA4-2: the round-3 → round-4 test flips left the U2M-arm + // defense-in-depth U2M+id rejection without coverage. It's still + // reachable: when `oauthClientId` is a blank-reserved literal + // (whitespace, `"null"`, `"undefined"`) AND `oauthClientSecret` is + // absent/blank, BOTH `idIsBlank` and `secretIsBlank` are true so + // U2M wins routing — but a non-undefined id signals ambiguity that + // U2M cannot honor (the kernel hardcodes `databricks-cli`). + it('routes a whitespace oauthClientId with no oauthClientSecret to the U2M defense-in-depth rejection', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: ' ', + } as unknown as ConnectionOptions; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + HiveDriverError, + /oauthClientId.*not supported on the OAuth U2M flow/, + ); + }); + }); + + describe('ambiguous credentials are rejected', () => { + it('rejects PAT path with stray oauthClientId', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'access-token', + token: 'dapi-fake-pat', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + oauthClientId: 'client-uuid', + } as any; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + HiveDriverError, + /cannot supply both `token` and `oauthClientId/, + ); + }); + + it('rejects PAT path with stray oauthClientSecret', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'access-token', + token: 'dapi-fake-pat', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + oauthClientSecret: 'dose-fake-secret', + } as any; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + HiveDriverError, + /cannot supply both `token` and `oauthClientId/, + ); + }); + + it('rejects M2M path with stray token', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + token: 'dapi-fake-pat', + } as any; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + HiveDriverError, + /cannot supply `token` alongside `authType: 'databricks-oauth'`/, + ); + }); + + it('rejects U2M path with stray token', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + // no client secret → would be U2M, but token is set → rejected first + // eslint-disable-next-line @typescript-eslint/no-explicit-any + token: 'dapi-fake-pat', + } as any; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + HiveDriverError, + /cannot supply `token` alongside `authType: 'databricks-oauth'`/, + ); + }); + + // NF-N3: a blank `oauthClientSecret` (the + // `process.env.MY_SECRET || ''` shape) should route to U2M, not + // to the M2M arm with an "empty secret" rejection. M2M's error + // message would never mention U2M, leaving the user stuck. + it('routes blank oauthClientSecret to U2M (not to an M2M-blank-secret rejection)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientSecret: '', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.authMode).to.equal('OAuthU2m'); + }); + + it('routes whitespace-only oauthClientSecret to U2M too', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientSecret: ' \t ', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.authMode).to.equal('OAuthU2m'); + }); + + it('routes literal-"undefined" oauthClientSecret to U2M too', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientSecret: 'undefined', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.authMode).to.equal('OAuthU2m'); + }); + }); + + describe('explicit-undefined vs missing for Azure-direct discriminants', () => { + it('accepts explicit `azureTenantId: undefined` on M2M (treated as not-set)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + azureTenantId: undefined, + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.authMode).to.equal('OAuthM2m'); + }); + + it('accepts `useDatabricksOAuthInAzure: false` on M2M (only `=== true` rejects)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + useDatabricksOAuthInAzure: false, + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.authMode).to.equal('OAuthM2m'); + }); + + it('accepts explicit `azureTenantId: undefined` on U2M too', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + azureTenantId: undefined, + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.authMode).to.equal('OAuthU2m'); + }); + }); +}); + +describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { + /** + * Build a fake binding whose `openSession` rejects with the verbatim + * `__databricks_error__:{...}` envelope shape the napi binding's + * `napi_err_from_kernel` produces. Used to exercise + * `decodeNapiKernelError` end-to-end without compiling the native + * module. + */ + function bindingRejectingWith(envelopeJson: string) { + const { binding } = makeFakeBinding(); + binding.openSession = (async () => { + throw new Error(`__databricks_error__:${envelopeJson}`); + }) as typeof binding.openSession; + return binding; + } + + const validConnectArgs: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: 'dapi-fake-pat', + }; + + it('maps Unauthenticated kernel envelope → AuthenticationError with kernel message preserved', async () => { + const binding = bindingRejectingWith( + '{"code":"Unauthenticated","message":"OAuth M2M token exchange failed: invalid_client"}', + ); + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(AuthenticationError); + expect((caught as Error).message).to.match(/invalid_client/); + }); + + it('maps NetworkError kernel envelope → HiveDriverError with kernel message preserved', async () => { + const binding = bindingRejectingWith( + '{"code":"NetworkError","message":"OIDC discovery failed: connection refused"}', + ); + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(HiveDriverError); + expect((caught as Error).message).to.match(/OIDC discovery failed/); + }); + + it('preserves SQLSTATE on the decoded error when present', async () => { + const binding = bindingRejectingWith('{"code":"Unauthenticated","message":"forbidden","sqlState":"28000"}'); + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(AuthenticationError); + expect((caught as { sqlState?: string }).sqlState).to.equal('28000'); + }); + + it('passes through plain napi errors (no sentinel) unchanged', async () => { + const { binding } = makeFakeBinding(); + binding.openSession = (async () => { + throw new Error('openSession: `token` is required for the requested auth mode'); + }) as typeof binding.openSession; + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(Error); + expect((caught as Error).message).to.match(/`token` is required/); + }); + + it('falls back to original Error for a corrupted envelope, stripping the internal sentinel', async () => { + const binding = bindingRejectingWith('not valid json'); + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + // Corrupted envelopes should NOT silently disappear — we return + // the original Error so the operator sees the raw payload. + expect(caught).to.be.instanceOf(Error); + expect((caught as Error).message).to.contain('not valid json'); + // Round-4 NF3-3: the `__databricks_error__:` prefix is an internal + // JS<->binding framing marker; it must not leak to the user-facing + // message even on the corrupted-envelope fallback path. + expect((caught as Error).message).to.not.match(/^__databricks_error__:/); + expect((caught as Error).message).to.equal('not valid json'); + }); + + // NF-4 / NF-N1: preserve the 5 optional kernel envelope fields on the + // decoded JS error under a single `kernelMetadata` namespace. + // Namespaced to avoid the collision with `OperationStateError.errorCode` + // and `RetryError.errorCode` (both pre-existing enum fields switched + // on at `DBSQLOperation.ts:209`). + it('preserves errorCode + vendorCode + httpStatus + retryable + queryId under kernelMetadata namespace', async () => { + const binding = bindingRejectingWith( + '{"code":"Unavailable","message":"upstream timed out",' + + '"sqlState":"08006","errorCode":"UPSTREAM_TIMEOUT","vendorCode":1234,' + + '"httpStatus":503,"retryable":true,"queryId":"query-abc-123"}', + ); + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = caught as any; + expect(err.sqlState).to.equal('08006'); + expect(err.kernelMetadata).to.deep.equal({ + errorCode: 'UPSTREAM_TIMEOUT', + vendorCode: 1234, + httpStatus: 503, + retryable: true, + queryId: 'query-abc-123', + }); + }); + + it('keeps sqlState and kernelMetadata non-enumerable (matches Node `.code` pattern)', async () => { + const binding = bindingRejectingWith('{"code":"NetworkError","message":"x","sqlState":"08000","httpStatus":502}'); + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + expect(Object.keys(caught as object)).to.not.include('sqlState'); + expect(Object.keys(caught as object)).to.not.include('kernelMetadata'); + // But direct access still works. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = caught as any; + expect(err.sqlState).to.equal('08000'); + expect(err.kernelMetadata?.httpStatus).to.equal(502); + }); + + // NF-N1: namespace must NOT clobber a pre-existing `errorCode` enum + // field on OperationStateError / RetryError. Cancelled envelopes map + // to OperationStateError(Canceled), and DBSQLOperation.ts:209 switches + // on `err.errorCode === OperationStateErrorCode.Canceled` — that must + // continue to read the enum 'CANCELED', not the kernel's textual + // errorCode. + it('does not clobber OperationStateError.errorCode enum when kernel envelope sends a textual errorCode', async () => { + const binding = bindingRejectingWith( + '{"code":"Cancelled","message":"user-cancel","errorCode":"USER_REQUESTED_CANCEL"}', + ); + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + // The enum-typed top-level errorCode is untouched (still the + // CANCELED enum string from OperationStateError's constructor). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = caught as any; + expect(err.errorCode).to.equal('CANCELED'); + // The kernel's textual errorCode survives under the namespace. + expect(err.kernelMetadata?.errorCode).to.equal('USER_REQUESTED_CANCEL'); + }); + + // NF-N4: per-field type guards. If the kernel sends a wrong-typed + // field (e.g. `retryable: "true"` string instead of `true` boolean), + // the decoder should drop that field rather than propagate the + // wrong type. + it('drops envelope fields with the wrong runtime type instead of passing them through', async () => { + // errorCode wrong-type (number instead of string), vendorCode + // wrong-type (string instead of number), httpStatus correct, + // retryable wrong-type (string instead of boolean), queryId null. + // Only httpStatus should survive the type-guard. + const binding = bindingRejectingWith( + '{"code":"NetworkError","message":"x","errorCode":42,"vendorCode":"not-a-number","httpStatus":502,"retryable":"true","queryId":null}', + ); + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = caught as any; + // Only the well-typed httpStatus survives. + expect(err.kernelMetadata).to.deep.equal({ httpStatus: 502 }); + }); + + it('omits the kernelMetadata namespace entirely when no envelope fields survive validation', async () => { + // A minimal envelope (just code + message + sqlState) yields an + // empty metadata object — and we should NOT attach a `{}`-shaped + // namespace because that's pure noise. The sqlState top-level + // field is unaffected. + const binding = bindingRejectingWith('{"code":"Internal","message":"x","sqlState":"08001"}'); + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = caught as any; + expect(err.sqlState).to.equal('08001'); + expect(err.kernelMetadata).to.equal(undefined); + }); + + // NF-1: SeaSessionBackend.close() must wrap the napi call too. + it('SeaSessionBackend.close() decodes kernel-error envelopes from native.close()', async () => { + const { binding } = makeFakeBinding(); + // Make openSession return a fake Connection whose close() throws + // a kernel-shaped envelope. + const failingClose = { + async executeStatement() { + throw new Error('unused'); + }, + async close() { + throw new Error('__databricks_error__:{"code":"Internal","message":"server-side close failed"}'); + }, + }; + binding.openSession = (async () => failingClose as unknown) as typeof binding.openSession; + + const backend = new SeaBackend({ nativeBinding: binding }); + await backend.connect(validConnectArgs); + const session = await backend.openSession({}); + + let caught: unknown; + try { + await session.close(); + } catch (e) { + caught = e; + } + // Before the NF-1 fix, this would surface as a raw Error whose + // message starts with `__databricks_error__:`. After the fix, the + // sentinel is stripped and the typed class is dispatched. + expect(caught).to.be.instanceOf(HiveDriverError); + expect((caught as Error).message).to.equal('server-side close failed'); + expect((caught as Error).message).to.not.contain('__databricks_error__'); + }); +}); diff --git a/tests/unit/sea/auth-m2m.test.ts b/tests/unit/sea/auth-m2m.test.ts new file mode 100644 index 00000000..3d93eb17 --- /dev/null +++ b/tests/unit/sea/auth-m2m.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import SeaBackend from '../../../lib/sea/SeaBackend'; +import { buildSeaConnectionOptions } from '../../../lib/sea/SeaAuth'; +import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; +import AuthenticationError from '../../../lib/errors/AuthenticationError'; +import HiveDriverError from '../../../lib/errors/HiveDriverError'; +import { makeFakeBinding } from './_helpers/fakeBinding'; + +describe('SeaAuth + SeaBackend — OAuth M2M auth flow', () => { + describe('buildSeaConnectionOptions', () => { + it('accepts databricks-oauth + oauthClientId + oauthClientSecret', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native).to.deep.equal({ + hostName: 'example.cloud.databricks.com', + httpPath: '/sql/1.0/warehouses/abc', + authMode: 'OAuthM2m', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + }); + }); + + it('prepends `/` to the path on the M2M branch too', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: 'sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.httpPath).to.equal('/sql/1.0/warehouses/abc'); + }); + + it('rejects missing oauthClientId with AuthenticationError', () => { + const opts = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientSecret: 'dose-fake-secret', + } as unknown as ConnectionOptions; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(AuthenticationError, /oauthClientId.*required/); + }); + + it('rejects empty oauthClientId with AuthenticationError', () => { + const opts = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: '', + oauthClientSecret: 'dose-fake-secret', + } as unknown as ConnectionOptions; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(AuthenticationError, /oauthClientId.*required/); + }); + + it('rejects empty oauthClientSecret with AuthenticationError when oauthClientId is set (M2M intent)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: '', + }; + + // Presence of `oauthClientId` signals M2M intent; an empty secret + // is a typo/missing-env, not a request to fall back to U2M. + // Surface the M2M "secret required" error so the user knows the + // real problem instead of getting routed to a different flow. + expect(() => buildSeaConnectionOptions(opts)).to.throw( + AuthenticationError, + /oauthClientSecret.*non-empty.*OAuth M2M/, + ); + }); + + it('rejects azureTenantId with a clear Entra-direct-out-of-scope error', () => { + const opts: ConnectionOptions = { + host: 'adb-12345.0.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + azureTenantId: 'tenant-uuid', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(HiveDriverError, /Azure-direct OAuth.*is not supported/); + }); + + it('rejects useDatabricksOAuthInAzure with the same Entra-direct error', () => { + const opts: ConnectionOptions = { + host: 'adb-12345.0.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + useDatabricksOAuthInAzure: true, + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(HiveDriverError, /Azure-direct OAuth.*is not supported/); + }); + + it('rejects a `persistence` hook on M2M (no cache needed)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + persistence: { + read: async () => undefined, + persist: async () => undefined, + }, + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + HiveDriverError, + /`persistence` is not supported on OAuth M2M/, + ); + }); + }); + + describe('SeaBackend.connect + openSession (M2M)', () => { + it('round-trips M2M options through to the napi binding', async () => { + const { binding, calls } = makeFakeBinding(); + const backend = new SeaBackend({ nativeBinding: binding }); + + await backend.connect({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + }); + + const session = await backend.openSession({}); + // Post-integration: SeaSessionBackend generates UUIDv4 ids; the + // earlier auth-only counter-id scheme was superseded. + expect(session.id).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + + expect(calls).to.have.lengthOf(1); + expect(calls[0].method).to.equal('openSession'); + expect(calls[0].args[0]).to.deep.equal({ + hostName: 'example.cloud.databricks.com', + httpPath: '/sql/1.0/warehouses/abc', + authMode: 'OAuthM2m', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + }); + + await session.close(); + await backend.close(); + }); + + it('rejects connect() for missing oauthClientId before touching the binding', async () => { + const { binding, calls } = makeFakeBinding(); + const backend = new SeaBackend({ nativeBinding: binding }); + + let caught: unknown; + try { + await backend.connect({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + oauthClientSecret: 'dose-fake-secret', + } as any); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(AuthenticationError); + expect(calls).to.have.lengthOf(0); + }); + }); +}); diff --git a/tests/unit/sea/auth-pat.test.ts b/tests/unit/sea/auth-pat.test.ts new file mode 100644 index 00000000..f59b445c --- /dev/null +++ b/tests/unit/sea/auth-pat.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { buildSeaConnectionOptions } from '../../../lib/sea/SeaAuth'; +import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; +import AuthenticationError from '../../../lib/errors/AuthenticationError'; +import HiveDriverError from '../../../lib/errors/HiveDriverError'; + +describe('SeaAuth — PAT auth options builder', () => { + describe('buildSeaConnectionOptions', () => { + it('accepts a bare access-token PAT (undefined authType)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: 'dapi-fake-pat', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native).to.deep.equal({ + hostName: 'example.cloud.databricks.com', + httpPath: '/sql/1.0/warehouses/abc', + authMode: 'Pat', + token: 'dapi-fake-pat', + }); + }); + + it('accepts an explicit access-token PAT', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'access-token', + token: 'dapi-fake-pat', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.authMode).to.equal('Pat'); + if (native.authMode === 'Pat') { + expect(native.token).to.equal('dapi-fake-pat'); + } + }); + + it('prepends `/` to a path missing the leading slash', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: 'sql/1.0/warehouses/abc', + token: 'dapi-fake-pat', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.httpPath).to.equal('/sql/1.0/warehouses/abc'); + }); + + it('throws AuthenticationError when token is missing', () => { + const opts = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'access-token', + // no token + } as unknown as ConnectionOptions; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(AuthenticationError, /non-empty PAT/); + }); + + it('throws AuthenticationError when token is an empty string', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: '', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(AuthenticationError, /non-empty PAT/); + }); + + it('accepts databricks-oauth without oauthClientSecret as the U2M happy path', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.authMode).to.equal('OAuthU2m'); + }); + + it('rejects token-provider with a clear unsupported-mode error', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'token-provider', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tokenProvider: { getToken: async () => 'tok' } as any, + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(HiveDriverError, /unsupported auth mode 'token-provider'/); + }); + + it('rejects external-token, static-token, and custom auth modes', () => { + const authTypes = ['external-token', 'static-token', 'custom'] as const; + for (const authType of authTypes) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const opts = { + host: 'h', + path: '/p', + authType, + } as any; + expect(() => buildSeaConnectionOptions(opts)).to.throw(HiveDriverError, /unsupported auth mode/); + } + }); + }); + + // Note: SeaBackend.connect/openSession round-trip + error-path coverage + // moved to tests/unit/sea/execution.test.ts during the sea-integration + // merge (the execution branch's SeaBackend constructor signature + // {context, nativeBinding} supersedes the auth-only (binding) shape). + // OAuth-specific flow-dispatch tests live in auth-m2m.test.ts and + // auth-u2m.test.ts; M2M end-to-end against a live workspace lives in + // tests/integration/sea/auth-m2m-e2e.test.ts. +}); diff --git a/tests/unit/sea/auth-u2m.test.ts b/tests/unit/sea/auth-u2m.test.ts new file mode 100644 index 00000000..75db4bbb --- /dev/null +++ b/tests/unit/sea/auth-u2m.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import SeaBackend from '../../../lib/sea/SeaBackend'; +import { buildSeaConnectionOptions } from '../../../lib/sea/SeaAuth'; +import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; +import AuthenticationError from '../../../lib/errors/AuthenticationError'; +import HiveDriverError from '../../../lib/errors/HiveDriverError'; +import { makeFakeBinding } from './_helpers/fakeBinding'; + +describe('SeaAuth + SeaBackend — OAuth U2M auth flow', () => { + describe('buildSeaConnectionOptions', () => { + it('accepts databricks-oauth with no clientSecret as the U2M happy path (hardcoded port 8030)', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native).to.deep.equal({ + hostName: 'example.cloud.databricks.com', + httpPath: '/sql/1.0/warehouses/abc', + authMode: 'OAuthU2m', + oauthRedirectPort: 8030, + }); + }); + + it('rejects oauthClientId without oauthClientSecret as M2M-with-missing-secret', () => { + // Round-4 NF3-2: presence of `oauthClientId` signals M2M intent. + // Routing now keys off the id (the "do I have an id?" signal), + // not the secret. A caller who supplies id but no secret gets the + // M2M "secret is required" error — the actionable message for the + // real problem (typo'd env var, forgot to export it, etc.). + // + // The U2M arm still has a defense-in-depth rejection of a stray + // `oauthClientId` (the kernel hardcodes `databricks-cli` for U2M); + // see [NF-2 / round-1 history]. That defense fires only when + // BOTH id and secret are blank — the M2M arm's stricter checks + // catch this typical caller-error shape first. + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'custom-client', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + AuthenticationError, + /oauthClientSecret.*non-empty.*OAuth M2M/, + ); + }); + + it('prepends `/` to the path on the U2M branch too', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: 'sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + }; + + const native = buildSeaConnectionOptions(opts); + expect(native.httpPath).to.equal('/sql/1.0/warehouses/abc'); + }); + + it('rejects azureTenantId on the U2M path with the Entra-direct error', () => { + const opts: ConnectionOptions = { + host: 'adb-12345.0.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + azureTenantId: 'tenant-uuid', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(HiveDriverError, /Azure-direct OAuth.*is not supported/); + }); + + it('rejects useDatabricksOAuthInAzure on the U2M path', () => { + const opts: ConnectionOptions = { + host: 'adb-12345.0.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + useDatabricksOAuthInAzure: true, + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(HiveDriverError, /Azure-direct OAuth.*is not supported/); + }); + + it('rejects a `persistence` hook on U2M citing the AuthConfig::External kernel-plumbing gap', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + persistence: { + read: async () => undefined, + persist: async () => undefined, + }, + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(HiveDriverError, /AuthConfig::External.*plumbing/); + }); + }); + + describe('SeaBackend.connect + openSession (U2M)', () => { + it('round-trips U2M options through to the napi binding', async () => { + const { binding, calls } = makeFakeBinding(); + const backend = new SeaBackend({ nativeBinding: binding }); + + await backend.connect({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + }); + + const session = await backend.openSession({}); + // Post-integration: SeaSessionBackend generates UUIDv4 ids; the + // earlier auth-only counter-id scheme was superseded. + expect(session.id).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + + expect(calls).to.have.lengthOf(1); + expect(calls[0].method).to.equal('openSession'); + expect(calls[0].args[0]).to.deep.equal({ + hostName: 'example.cloud.databricks.com', + httpPath: '/sql/1.0/warehouses/abc', + authMode: 'OAuthU2m', + oauthRedirectPort: 8030, + }); + + await session.close(); + await backend.close(); + }); + }); +}); diff --git a/tests/unit/sea/error-mapping.test.ts b/tests/unit/sea/error-mapping.test.ts new file mode 100644 index 00000000..8b5bdf70 --- /dev/null +++ b/tests/unit/sea/error-mapping.test.ts @@ -0,0 +1,221 @@ +import { expect } from 'chai'; +import { mapKernelErrorToJsError, KernelErrorCode, KernelErrorShape } from '../../../lib/sea/SeaErrorMapping'; +import HiveDriverError from '../../../lib/errors/HiveDriverError'; +import AuthenticationError from '../../../lib/errors/AuthenticationError'; +import OperationStateError, { OperationStateErrorCode } from '../../../lib/errors/OperationStateError'; +import ParameterError from '../../../lib/errors/ParameterError'; + +describe('SeaErrorMapping.mapKernelErrorToJsError', () => { + // The 13 kernel ErrorCode variants — kept in sync with src/kernel_error.rs:66-134. + // Tabular driver: each row is (kernel code, expected class, optional extra assertion). + type Case = { + code: KernelErrorCode; + expectedClass: Function; + extra?: (err: Error) => void; + }; + + const cases: Array = [ + { + code: 'InvalidArgument', + expectedClass: ParameterError, + }, + { + code: 'Unauthenticated', + expectedClass: AuthenticationError, + }, + { + code: 'PermissionDenied', + expectedClass: AuthenticationError, + }, + { + code: 'NotFound', + expectedClass: HiveDriverError, + }, + { + code: 'ResourceExhausted', + expectedClass: HiveDriverError, + }, + { + code: 'Unavailable', + expectedClass: HiveDriverError, + }, + { + code: 'Timeout', + expectedClass: OperationStateError, + extra: (err) => { + expect((err as OperationStateError).errorCode).to.equal(OperationStateErrorCode.Timeout); + }, + }, + { + code: 'Cancelled', + expectedClass: OperationStateError, + extra: (err) => { + expect((err as OperationStateError).errorCode).to.equal(OperationStateErrorCode.Canceled); + }, + }, + { + code: 'DataLoss', + expectedClass: HiveDriverError, + }, + { + code: 'Internal', + expectedClass: HiveDriverError, + }, + { + code: 'InvalidStatementHandle', + expectedClass: HiveDriverError, + }, + { + code: 'NetworkError', + expectedClass: HiveDriverError, + }, + { + code: 'SqlError', + expectedClass: HiveDriverError, + }, + ]; + + it('covers all 13 kernel ErrorCode variants', () => { + // Guardrail: if the kernel adds a variant, KernelErrorCode in TS will gain + // a literal — this test then fails because the new variant has no case row. + // (Drift is caught at the test level since the union itself is an inline literal.) + expect(cases).to.have.lengthOf(13); + }); + + cases.forEach(({ code, expectedClass, extra }) => { + it(`maps ${code} to ${expectedClass.name}`, () => { + const kErr: KernelErrorShape = { + code, + message: `kernel ${code} message`, + }; + + const err = mapKernelErrorToJsError(kErr); + + expect(err).to.be.instanceOf(expectedClass); + expect(err.message).to.equal(`kernel ${code} message`); + if (extra) { + extra(err); + } + }); + }); + + describe('SQLSTATE preservation', () => { + it('attaches sqlState when present on the kernel error', () => { + const err = mapKernelErrorToJsError({ + code: 'SqlError', + message: 'syntax error', + sqlstate: '42000', + }); + + expect(err).to.be.instanceOf(HiveDriverError); + expect(err.sqlState).to.equal('42000'); + }); + + it('does not set sqlState when absent', () => { + const err = mapKernelErrorToJsError({ + code: 'Internal', + message: 'boom', + }); + + expect(err.sqlState).to.be.undefined; + }); + + it('preserves sqlState on AuthenticationError', () => { + const err = mapKernelErrorToJsError({ + code: 'Unauthenticated', + message: 'invalid token', + sqlstate: '28000', + }); + + expect(err).to.be.instanceOf(AuthenticationError); + expect(err.sqlState).to.equal('28000'); + }); + + it('preserves sqlState on OperationStateError', () => { + const err = mapKernelErrorToJsError({ + code: 'Timeout', + message: 'deadline exceeded', + sqlstate: 'HYT01', + }); + + expect(err).to.be.instanceOf(OperationStateError); + expect((err as OperationStateError).errorCode).to.equal(OperationStateErrorCode.Timeout); + expect(err.sqlState).to.equal('HYT01'); + }); + + it('preserves sqlState on ParameterError', () => { + const err = mapKernelErrorToJsError({ + code: 'InvalidArgument', + message: 'bad param', + sqlstate: 'HY009', + }); + + expect(err).to.be.instanceOf(ParameterError); + expect(err.sqlState).to.equal('HY009'); + }); + + it('attaches sqlState as a non-enumerable property', () => { + const err = mapKernelErrorToJsError({ + code: 'SqlError', + message: 'oops', + sqlstate: '42000', + }); + + const descriptor = Object.getOwnPropertyDescriptor(err, 'sqlState'); + expect(descriptor).to.exist; + expect(descriptor!.enumerable).to.equal(false); + expect(descriptor!.writable).to.equal(true); + expect(descriptor!.configurable).to.equal(true); + }); + }); + + describe('unknown / future kernel codes', () => { + it('falls back to HiveDriverError for an unrecognised code', () => { + const err = mapKernelErrorToJsError({ + code: 'SomeFutureVariantThatDoesNotExist', + message: 'forward-compat message', + }); + + // Never silently drop — must surface as the base driver class. + expect(err).to.be.instanceOf(HiveDriverError); + expect(err.message).to.equal('forward-compat message'); + }); + + it('still preserves sqlState on a fallback HiveDriverError', () => { + const err = mapKernelErrorToJsError({ + code: 'BrandNewVariant', + message: 'with sqlstate', + sqlstate: '01004', + }); + + expect(err).to.be.instanceOf(HiveDriverError); + expect(err.sqlState).to.equal('01004'); + }); + }); + + describe('returned errors compose with try/catch', () => { + it('thrown errors are catchable as Error', () => { + function thrower() { + throw mapKernelErrorToJsError({ code: 'Internal', message: 'kaboom' }); + } + + expect(thrower).to.throw(Error, 'kaboom'); + expect(thrower).to.throw(HiveDriverError, 'kaboom'); + }); + + it('AuthenticationError thrown is also instanceOf HiveDriverError', () => { + // AuthenticationError extends HiveDriverError — preserve that hierarchy. + const err = mapKernelErrorToJsError({ code: 'Unauthenticated', message: 'nope' }); + expect(err).to.be.instanceOf(AuthenticationError); + expect(err).to.be.instanceOf(HiveDriverError); + expect(err).to.be.instanceOf(Error); + }); + + it('ParameterError does NOT extend HiveDriverError (matches existing class hierarchy)', () => { + const err = mapKernelErrorToJsError({ code: 'InvalidArgument', message: 'bad' }); + expect(err).to.be.instanceOf(ParameterError); + expect(err).to.not.be.instanceOf(HiveDriverError); + expect(err).to.be.instanceOf(Error); + }); + }); +}); From f6418e012f637761de6356831d6c22b6de5117b7 Mon Sep 17 00:00:00 2001 From: Madhavendra Rathore Date: Mon, 1 Jun 2026 13:42:25 +0000 Subject: [PATCH 2/2] fix(sea): address review on #409 (packaging, auth-flow docs, context) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P0 packaging: the napi router's per-platform npm names were garbled — the M0 triple was baked into the prefix for every platform (@databricks/sea-native-linux-x64-gnu-, and the doubled ...-gnu-linux-x64-gnu). Corrected to the canonical @databricks/sql-kernel- (matches the loader hint + native/sea/README). Added native-packaging.test to lock the naming. The per-platform packages are unpublished, so they remain undeclared in optionalDependencies (npm ci can't resolve an unpublished pin); README documents the M0 build:native load path. - Moved @napi-rs/cli out of optionalDependencies (a build tool should not land in consumer installs); build:native now fetches it on demand via npx --yes. - P1 auth-flow: documented honestly that SEA's OAuth flow selection DIVERGES from Thrift — Thrift keys off the secret (DBSQLClient.ts:216), SEA keys off oauthClientId presence — including the two real gaps (id+no-secret throws; U2M has no custom client id). Fixed the stale DBSQLClient.ts:143 ref. - P1 stale ref: SeaErrorMapping pointed at DBSQLOperation.ts:209; the Canceled switch is now ~:374. Updated. - P2 context: SeaBackendOptions.context is now required (was an `as IClientContext` downcast of undefined → latent NPE). Tests pass a shared makeFakeContext(). - P2 prependSlash: dropped the dead lib/utils/prependSlash.ts (nothing imported it; SeaAuth and DBSQLClient each keep their own inline copy). Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore --- lib/sea/SeaAuth.ts | 38 +++++++++++------ lib/sea/SeaBackend.ts | 18 ++++---- lib/sea/SeaErrorMapping.ts | 6 +-- lib/utils/prependSlash.ts | 25 ----------- native/sea/README.md | 23 +++++++---- native/sea/index.js | 36 ++++++++-------- package-lock.json | 24 ----------- package.json | 5 +-- tests/unit/sea/_helpers/fakeBinding.ts | 20 +++++++++ tests/unit/sea/auth-edge-cases.test.ts | 24 +++++------ tests/unit/sea/auth-m2m.test.ts | 6 +-- tests/unit/sea/auth-u2m.test.ts | 4 +- tests/unit/sea/native-packaging.test.ts | 55 +++++++++++++++++++++++++ 13 files changed, 165 insertions(+), 119 deletions(-) delete mode 100644 lib/utils/prependSlash.ts create mode 100644 tests/unit/sea/native-packaging.test.ts diff --git a/lib/sea/SeaAuth.ts b/lib/sea/SeaAuth.ts index 5f357131..bdfabf3d 100644 --- a/lib/sea/SeaAuth.ts +++ b/lib/sea/SeaAuth.ts @@ -128,11 +128,23 @@ export function isBlankOrReserved(s: string): boolean { * - OAuth U2M: `authType: 'databricks-oauth'` + NO `oauthClientId` and * NO `oauthClientSecret`. Kernel runs the PKCE auth-code dance (opens * a browser, listens on localhost:8030, exchanges the code, persists - * to `~/.config/databricks-sql-kernel/oauth/{sha256}.json`). The flow - * selector keys off `oauthClientId` presence: present → M2M, absent → - * U2M. (Round-4 NF3-2 fix; previously secret-keyed — that variant - * routed a typo'd-secret M2M call to the U2M arm and swallowed the - * actionable error.) Mirrors thrift's intent at `DBSQLClient.ts:143`. + * to `~/.config/databricks-sql-kernel/oauth/{sha256}.json`). + * + * **Flow selection — DELIBERATE DIVERGENCE FROM THRIFT.** Thrift's + * `DBSQLClient.createAuthProvider` (`DBSQLClient.ts:216`) keys off the + * *secret* (`oauthClientSecret === undefined ? U2M : M2M`), so a custom + * `oauthClientId` with no secret runs U2M with that id. SEA instead keys + * off `oauthClientId` *presence* (id present → M2M, absent → U2M). The + * trade-off: keying off the id means a caller who set an id but + * typoed/forgot the secret gets the actionable M2M "secret is required" + * error instead of being silently routed to U2M (which would hide their + * intent). The cost is two real behavioural gaps vs Thrift: + * 1. `oauthClientId` + no secret → Thrift runs U2M; SEA throws + * `AuthenticationError` (M2M secret required). + * 2. SEA U2M has NO custom-client-id support — the kernel hardcodes + * `client_id = "databricks-cli"`, and SEA rejects any `oauthClientId` + * on the U2M arm. Thrift U2M honours a custom `clientId`. + * Both are documented limitations of the M0 SEA OAuth surface, not bugs. * * Out of scope on the OAuth paths (rejected with a clear error): * - `azureTenantId` / `useDatabricksOAuthInAzure` → Microsoft Entra @@ -204,13 +216,15 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative ); } - // Flow selector mirrors thrift's `DBSQLClient.createAuthProvider` - // (`DBSQLClient.ts:143`): presence of `oauthClientId` indicates M2M - // intent, otherwise U2M. Routing decision is based on `oauthClientId` - // (the "do I have an id?" signal) rather than the secret, so a - // user who set an id but typoed/forgot the secret gets the M2M - // "secret is required" error instead of a U2M error that hides - // their actual intent. The U2M arm still defends against an id + // Flow selector — DELIBERATELY DIFFERENT from thrift's + // `DBSQLClient.createAuthProvider` (`DBSQLClient.ts:216`), which keys off + // the secret (`oauthClientSecret === undefined ? U2M : M2M`). SEA keys off + // `oauthClientId` *presence* (the "do I have an id?" signal) instead, so a + // user who set an id but typoed/forgot the secret gets the actionable M2M + // "secret is required" error rather than being silently routed to U2M + // (which would hide their intent). Cost: `id + no secret` throws here + // where thrift would run U2M, and SEA U2M has no custom-client-id support + // (see buildSeaConnectionOptions header). The U2M arm still defends against an id // sneaking through: fires only when `oauthClientId` is provided as // a blank-reserved literal (e.g., whitespace, `"null"`, `"undefined"`) // alongside an absent/blank secret — both `idIsBlank` and diff --git a/lib/sea/SeaBackend.ts b/lib/sea/SeaBackend.ts index 472d7553..1043da8d 100644 --- a/lib/sea/SeaBackend.ts +++ b/lib/sea/SeaBackend.ts @@ -24,13 +24,13 @@ import SeaSessionBackend from './SeaSessionBackend'; export interface SeaBackendOptions { /** - * Optional in the type so unit tests that only exercise the auth- - * routing surface (which doesn't touch context) can pass - * `{ nativeBinding }`. The constructor downcasts undefined to - * `IClientContext` because runtime callers from `DBSQLClient` always - * supply one — see `lib/DBSQLClient.ts` SEA seam. + * Required. Provides the logger + config the SEA session/operation chain + * logs through. `DBSQLClient` supplies it via the SEA seam + * (`new SeaBackend({ context: this })`); unit tests pass a stub. Kept + * mandatory (rather than an `as IClientContext` downcast of `undefined`) + * so a missing context is a compile error, not a latent runtime NPE. */ - context?: IClientContext; + context: IClientContext; /** * Optional injection seam for unit tests. When provided, replaces the * default `getSeaNative()` call so tests can swap in a mock napi @@ -68,9 +68,9 @@ export default class SeaBackend implements IBackend { private nativeOptions?: SeaNativeConnectionOptions; - constructor(options?: SeaBackendOptions) { - this.context = options?.context as IClientContext; - this.binding = options?.nativeBinding ?? getSeaNative(); + constructor(options: SeaBackendOptions) { + this.context = options.context; + this.binding = options.nativeBinding ?? getSeaNative(); } public async connect(options: ConnectionOptions): Promise { diff --git a/lib/sea/SeaErrorMapping.ts b/lib/sea/SeaErrorMapping.ts index d7bec2ee..b17d594a 100644 --- a/lib/sea/SeaErrorMapping.ts +++ b/lib/sea/SeaErrorMapping.ts @@ -57,8 +57,8 @@ export type KernelErrorCode = * * `errorCode` is namespaced under `kernelMetadata` rather than placed at * the top level because `OperationStateError` already declares a top-level - * `errorCode: enum` field, and `DBSQLOperation.ts:209` switches on it - * (`err.errorCode === OperationStateErrorCode.Canceled`). Top-level + * `errorCode: enum` field, and `DBSQLOperation.ts` switches on it + * (`err.errorCode === OperationStateErrorCode.Canceled`, around `:374`). Top-level * defineProperty would clobber that enum with a kernel string and break * cancel/close detection. */ @@ -210,7 +210,7 @@ function buildKernelMetadata(parsed: Record): KernelMetadata { * envelope fields under a single non-enumerable `kernelMetadata` * namespace. Namespacing avoids the collision with * `OperationStateError.errorCode` (an enum already switched on at the - * JS layer — see `DBSQLOperation.ts:209`). + * JS layer — see `DBSQLOperation.ts` around `:374`). * - Binding-side error (e.g. `napi::Error::new(InvalidArg, "openSession: * \`token\` is required for the requested auth mode")` produced by * the binding's own validation): returned unchanged. These don't diff --git a/lib/utils/prependSlash.ts b/lib/utils/prependSlash.ts deleted file mode 100644 index a3ed7d92..00000000 --- a/lib/utils/prependSlash.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2026 Databricks, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * Normalise an HTTP path to a leading-slash form. Empty strings are left - * untouched. Shared by the Thrift connect path (`DBSQLClient`) and the - * SEA auth adapter (`SeaAuth`) so the two can't drift. - */ -export default function prependSlash(str: string): string { - if (str.length > 0 && str.charAt(0) !== '/') { - return `/${str}`; - } - return str; -} diff --git a/native/sea/README.md b/native/sea/README.md index 2a246059..c5b57b05 100644 --- a/native/sea/README.md +++ b/native/sea/README.md @@ -57,17 +57,24 @@ nodejs repo. At release time the kernel's CI publishes `@databricks/sql-kernel-` npm packages — one per supported -platform — each containing a single `.node` binary. The nodejs -driver lists them as `optionalDependencies`; npm installs only the -one matching the consumer's `process.platform` / `process.arch`. -`native/sea/index.js` (the napi-rs router) then `require()`s the -installed package at load time. +platform — each containing a single `.node` binary. `native/sea/index.js` +(the napi-rs router) `require()`s the package matching the consumer's +`process.platform` / `process.arch` at load time. + +> **M0 status:** these per-platform packages are **not yet published**, so +> they are intentionally **not** declared in the driver's +> `optionalDependencies`. (npm refuses an `npm ci` against a pinned +> dependency it cannot resolve from the registry, so declaring an +> unpublished package would break every install.) Until they ship, the +> binding is produced locally via `npm run build:native` (which copies +> `index..node` into this directory). Once the packages are +> published, add `@databricks/sql-kernel-` back to +> `optionalDependencies` — npm then installs only the matching one. ## Supported platforms (M0) -M0 publishes a **single** triple: **`linux-x64-gnu`** (package -`@databricks/sql-kernel-linux-x64-gnu`). It is the only entry in the -driver's `optionalDependencies`. +M0 targets a **single** triple: **`linux-x64-gnu`** (package +`@databricks/sql-kernel-linux-x64-gnu`, once published). On every other platform (macOS, Windows, linux-arm64, linux-x64-musl / Alpine, …) the SEA binding is simply absent: `SeaNativeLoader` diff --git a/native/sea/index.js b/native/sea/index.js index 5ce7146d..671fa931 100644 --- a/native/sea/index.js +++ b/native/sea/index.js @@ -37,7 +37,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.android-arm64.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-android-arm64') + nativeBinding = require('@databricks/sql-kernel-android-arm64') } } catch (e) { loadError = e @@ -49,7 +49,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.android-arm-eabi.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-android-arm-eabi') + nativeBinding = require('@databricks/sql-kernel-android-arm-eabi') } } catch (e) { loadError = e @@ -69,7 +69,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.win32-x64-msvc.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-win32-x64-msvc') + nativeBinding = require('@databricks/sql-kernel-win32-x64-msvc') } } catch (e) { loadError = e @@ -83,7 +83,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.win32-ia32-msvc.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-win32-ia32-msvc') + nativeBinding = require('@databricks/sql-kernel-win32-ia32-msvc') } } catch (e) { loadError = e @@ -97,7 +97,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.win32-arm64-msvc.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-win32-arm64-msvc') + nativeBinding = require('@databricks/sql-kernel-win32-arm64-msvc') } } catch (e) { loadError = e @@ -113,7 +113,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.darwin-universal.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-darwin-universal') + nativeBinding = require('@databricks/sql-kernel-darwin-universal') } break } catch {} @@ -124,7 +124,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.darwin-x64.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-darwin-x64') + nativeBinding = require('@databricks/sql-kernel-darwin-x64') } } catch (e) { loadError = e @@ -138,7 +138,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.darwin-arm64.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-darwin-arm64') + nativeBinding = require('@databricks/sql-kernel-darwin-arm64') } } catch (e) { loadError = e @@ -157,7 +157,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.freebsd-x64.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-freebsd-x64') + nativeBinding = require('@databricks/sql-kernel-freebsd-x64') } } catch (e) { loadError = e @@ -174,7 +174,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-x64-musl.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-x64-musl') + nativeBinding = require('@databricks/sql-kernel-linux-x64-musl') } } catch (e) { loadError = e @@ -187,7 +187,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-x64-gnu.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-x64-gnu') + nativeBinding = require('@databricks/sql-kernel-linux-x64-gnu') } } catch (e) { loadError = e @@ -203,7 +203,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-arm64-musl.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-arm64-musl') + nativeBinding = require('@databricks/sql-kernel-linux-arm64-musl') } } catch (e) { loadError = e @@ -216,7 +216,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-arm64-gnu.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-arm64-gnu') + nativeBinding = require('@databricks/sql-kernel-linux-arm64-gnu') } } catch (e) { loadError = e @@ -232,7 +232,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-arm-musleabihf.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-arm-musleabihf') + nativeBinding = require('@databricks/sql-kernel-linux-arm-musleabihf') } } catch (e) { loadError = e @@ -245,7 +245,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-arm-gnueabihf.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-arm-gnueabihf') + nativeBinding = require('@databricks/sql-kernel-linux-arm-gnueabihf') } } catch (e) { loadError = e @@ -261,7 +261,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-riscv64-musl.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-riscv64-musl') + nativeBinding = require('@databricks/sql-kernel-linux-riscv64-musl') } } catch (e) { loadError = e @@ -274,7 +274,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-riscv64-gnu.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-riscv64-gnu') + nativeBinding = require('@databricks/sql-kernel-linux-riscv64-gnu') } } catch (e) { loadError = e @@ -289,7 +289,7 @@ switch (platform) { if (localFileExisted) { nativeBinding = require('./index.linux-s390x-gnu.node') } else { - nativeBinding = require('@databricks/sea-native-linux-x64-gnu-linux-s390x-gnu') + nativeBinding = require('@databricks/sql-kernel-linux-s390x-gnu') } } catch (e) { loadError = e diff --git a/package-lock.json b/package-lock.json index 35955d5b..ee7678d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,6 @@ "node": ">=14.0.0" }, "optionalDependencies": { - "@napi-rs/cli": "2.18.4", "lz4": "^0.6.5" } }, @@ -834,23 +833,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/cli": { - "version": "2.18.4", - "resolved": "https://npm-proxy.dev.databricks.com/@napi-rs/cli/-/cli-2.18.4.tgz", - "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", - "license": "MIT", - "optional": true, - "bin": { - "napi": "scripts/index.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7033,12 +7015,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "@napi-rs/cli": { - "version": "2.18.4", - "resolved": "https://npm-proxy.dev.databricks.com/@napi-rs/cli/-/cli-2.18.4.tgz", - "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", - "optional": true - }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index fa36c2f6..8eca3135 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test": "nyc --report-dir=${NYC_REPORT_DIR:-coverage_unit} mocha --config tests/unit/.mocharc.js", "update-version": "node bin/update-version.js && prettier --write ./lib/version.ts", "build": "npm run update-version && tsc --project tsconfig.build.json", - "build:native": "bash -c 'cd ${DATABRICKS_SQL_KERNEL_REPO:-../../databricks-sql-kernel}/napi && npx --no-install @napi-rs/cli build --platform ${BUILD_PROFILE:---release} && cp index.* $OLDPWD/native/sea/'", + "build:native": "bash -c 'cd ${DATABRICKS_SQL_KERNEL_REPO:-../../databricks-sql-kernel}/napi && npx --yes @napi-rs/cli@2.18.4 build --platform ${BUILD_PROFILE:---release} && cp index.* $OLDPWD/native/sea/'", "prepack": "test -f native/sea/index.js || { echo 'ERROR: native/sea/index.js (napi-rs router) is missing — the published tarball would fail to load SEA. It is committed to git; run `npm run build:native` if you removed it.' >&2; exit 1; }", "watch": "tsc --project tsconfig.build.json --watch", "type-check": "tsc --noEmit", @@ -91,7 +91,6 @@ "winston": "^3.8.2" }, "optionalDependencies": { - "lz4": "^0.6.5", - "@napi-rs/cli": "2.18.4" + "lz4": "^0.6.5" } } diff --git a/tests/unit/sea/_helpers/fakeBinding.ts b/tests/unit/sea/_helpers/fakeBinding.ts index bffaad9f..16c101de 100644 --- a/tests/unit/sea/_helpers/fakeBinding.ts +++ b/tests/unit/sea/_helpers/fakeBinding.ts @@ -13,12 +13,32 @@ // limitations under the License. import { SeaNativeBinding, SeaConnection } from '../../../../lib/sea/SeaNativeLoader'; +import IClientContext from '../../../../lib/contracts/IClientContext'; export interface RecordedCall { method: string; args: unknown[]; } +/** + * Minimal `IClientContext` for SEA backend tests. `SeaBackend` requires a + * context (it threads logger/config into the session → operation chain), so + * even auth-routing tests that never log must supply one. Only `getLogger` + * is wired (to a no-op); the rest throw if unexpectedly reached. + */ +export function makeFakeContext(): IClientContext { + const notUsed = () => { + throw new Error('IClientContext member not expected to be used by this test'); + }; + return { + getConfig: () => ({} as ReturnType), + getLogger: () => ({ log: () => {} }), + getConnectionProvider: notUsed, + getClient: notUsed, + getDriver: notUsed, + } as unknown as IClientContext; +} + export interface FakeBinding { binding: SeaNativeBinding; calls: RecordedCall[]; diff --git a/tests/unit/sea/auth-edge-cases.test.ts b/tests/unit/sea/auth-edge-cases.test.ts index 72f40333..9f0928bb 100644 --- a/tests/unit/sea/auth-edge-cases.test.ts +++ b/tests/unit/sea/auth-edge-cases.test.ts @@ -18,7 +18,7 @@ import { buildSeaConnectionOptions } from '../../../lib/sea/SeaAuth'; import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; import AuthenticationError from '../../../lib/errors/AuthenticationError'; import HiveDriverError from '../../../lib/errors/HiveDriverError'; -import { makeFakeBinding } from './_helpers/fakeBinding'; +import { makeFakeBinding, makeFakeContext } from './_helpers/fakeBinding'; describe('SeaAuth — edge cases (input validation + ambiguity)', () => { describe('whitespace-only and reserved-literal credentials are rejected', () => { @@ -366,7 +366,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { const binding = bindingRejectingWith( '{"code":"Unauthenticated","message":"OAuth M2M token exchange failed: invalid_client"}', ); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); let caught: unknown; @@ -383,7 +383,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { const binding = bindingRejectingWith( '{"code":"NetworkError","message":"OIDC discovery failed: connection refused"}', ); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); let caught: unknown; @@ -398,7 +398,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { it('preserves SQLSTATE on the decoded error when present', async () => { const binding = bindingRejectingWith('{"code":"Unauthenticated","message":"forbidden","sqlState":"28000"}'); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); let caught: unknown; @@ -416,7 +416,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { binding.openSession = (async () => { throw new Error('openSession: `token` is required for the requested auth mode'); }) as typeof binding.openSession; - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); let caught: unknown; @@ -431,7 +431,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { it('falls back to original Error for a corrupted envelope, stripping the internal sentinel', async () => { const binding = bindingRejectingWith('not valid json'); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); let caught: unknown; @@ -462,7 +462,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { '"sqlState":"08006","errorCode":"UPSTREAM_TIMEOUT","vendorCode":1234,' + '"httpStatus":503,"retryable":true,"queryId":"query-abc-123"}', ); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); let caught: unknown; @@ -485,7 +485,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { it('keeps sqlState and kernelMetadata non-enumerable (matches Node `.code` pattern)', async () => { const binding = bindingRejectingWith('{"code":"NetworkError","message":"x","sqlState":"08000","httpStatus":502}'); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); let caught: unknown; @@ -513,7 +513,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { const binding = bindingRejectingWith( '{"code":"Cancelled","message":"user-cancel","errorCode":"USER_REQUESTED_CANCEL"}', ); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); let caught: unknown; @@ -543,7 +543,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { const binding = bindingRejectingWith( '{"code":"NetworkError","message":"x","errorCode":42,"vendorCode":"not-a-number","httpStatus":502,"retryable":"true","queryId":null}', ); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); let caught: unknown; @@ -564,7 +564,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { // namespace because that's pure noise. The sqlState top-level // field is unaffected. const binding = bindingRejectingWith('{"code":"Internal","message":"x","sqlState":"08001"}'); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); let caught: unknown; @@ -594,7 +594,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => { }; binding.openSession = (async () => failingClose as unknown) as typeof binding.openSession; - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect(validConnectArgs); const session = await backend.openSession({}); diff --git a/tests/unit/sea/auth-m2m.test.ts b/tests/unit/sea/auth-m2m.test.ts index 3d93eb17..a4f90ed5 100644 --- a/tests/unit/sea/auth-m2m.test.ts +++ b/tests/unit/sea/auth-m2m.test.ts @@ -18,7 +18,7 @@ import { buildSeaConnectionOptions } from '../../../lib/sea/SeaAuth'; import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; import AuthenticationError from '../../../lib/errors/AuthenticationError'; import HiveDriverError from '../../../lib/errors/HiveDriverError'; -import { makeFakeBinding } from './_helpers/fakeBinding'; +import { makeFakeBinding, makeFakeContext } from './_helpers/fakeBinding'; describe('SeaAuth + SeaBackend — OAuth M2M auth flow', () => { describe('buildSeaConnectionOptions', () => { @@ -145,7 +145,7 @@ describe('SeaAuth + SeaBackend — OAuth M2M auth flow', () => { describe('SeaBackend.connect + openSession (M2M)', () => { it('round-trips M2M options through to the napi binding', async () => { const { binding, calls } = makeFakeBinding(); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect({ host: 'example.cloud.databricks.com', @@ -176,7 +176,7 @@ describe('SeaAuth + SeaBackend — OAuth M2M auth flow', () => { it('rejects connect() for missing oauthClientId before touching the binding', async () => { const { binding, calls } = makeFakeBinding(); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); let caught: unknown; try { diff --git a/tests/unit/sea/auth-u2m.test.ts b/tests/unit/sea/auth-u2m.test.ts index 75db4bbb..c8f63fef 100644 --- a/tests/unit/sea/auth-u2m.test.ts +++ b/tests/unit/sea/auth-u2m.test.ts @@ -18,7 +18,7 @@ import { buildSeaConnectionOptions } from '../../../lib/sea/SeaAuth'; import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; import AuthenticationError from '../../../lib/errors/AuthenticationError'; import HiveDriverError from '../../../lib/errors/HiveDriverError'; -import { makeFakeBinding } from './_helpers/fakeBinding'; +import { makeFakeBinding, makeFakeContext } from './_helpers/fakeBinding'; describe('SeaAuth + SeaBackend — OAuth U2M auth flow', () => { describe('buildSeaConnectionOptions', () => { @@ -114,7 +114,7 @@ describe('SeaAuth + SeaBackend — OAuth U2M auth flow', () => { describe('SeaBackend.connect + openSession (U2M)', () => { it('round-trips U2M options through to the napi binding', async () => { const { binding, calls } = makeFakeBinding(); - const backend = new SeaBackend({ nativeBinding: binding }); + const backend = new SeaBackend({ nativeBinding: binding, context: makeFakeContext() }); await backend.connect({ host: 'example.cloud.databricks.com', diff --git a/tests/unit/sea/native-packaging.test.ts b/tests/unit/sea/native-packaging.test.ts new file mode 100644 index 00000000..b2732673 --- /dev/null +++ b/tests/unit/sea/native-packaging.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +// Guards the napi-rs router's per-platform npm package names. A misconfigured +// `npmName` once baked the M0 triple into the prefix for *every* platform +// (e.g. `@databricks/sea-native-linux-x64-gnu-darwin-arm64`, and the doubled +// `@databricks/sea-native-linux-x64-gnu-linux-x64-gnu`), so a published +// install would never resolve a `.node`. The canonical name is +// `@databricks/sql-kernel-` (see native/sea/README.md and the +// SeaNativeLoader load-failure hint). +describe('SEA native binding — packaging (native/sea/index.js)', () => { + // Resolved from the repo root (the cwd for `npm test`) so the test does not + // depend on the module system's `__dirname`. + const indexJs = readFileSync(join(process.cwd(), 'native/sea/index.js'), 'utf8'); + + // Every `require('@databricks/...')` fallback in the generated router. + const required = Array.from(indexJs.matchAll(/require\('(@databricks\/[^']+)'\)/g)).map((m) => m[1]); + + it('declares at least one @databricks/* npm fallback', () => { + expect(required.length, 'no @databricks/* require() found in the router').to.be.greaterThan(0); + }); + + it('every npm fallback uses the canonical @databricks/sql-kernel- name', () => { + const triple = /^@databricks\/sql-kernel-[a-z0-9]+(-[a-z0-9]+)*$/; + for (const name of required) { + expect(name, `unexpected SEA native package name: ${name}`).to.match(triple); + } + }); + + it('contains no garbled / doubled triple prefix', () => { + expect(indexJs, 'router still references the garbled sea-native prefix').to.not.contain('sea-native'); + expect(indexJs, 'router still doubles the linux-x64-gnu triple').to.not.contain('linux-x64-gnu-linux-x64-gnu'); + }); + + it('resolves the M0 linux-x64-gnu triple to @databricks/sql-kernel-linux-x64-gnu', () => { + expect(required, 'M0 supported triple package missing from the router').to.include( + '@databricks/sql-kernel-linux-x64-gnu', + ); + }); +});