diff --git a/lib/sea/SeaAuth.ts b/lib/sea/SeaAuth.ts new file mode 100644 index 00000000..cf16c80f --- /dev/null +++ b/lib/sea/SeaAuth.ts @@ -0,0 +1,83 @@ +// 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'; + +/** + * Shape consumed by the napi-binding's `openSession()` (see + * `native/sea/index.d.ts`). M0 supports PAT only — `token` is required. + * + * Mirrors `ConnectionOptions` in the binding's `.d.ts`; declared locally + * to avoid coupling the JS-side adapter to the auto-generated TS file. + */ +export interface SeaNativeConnectionOptions { + hostName: string; + httpPath: string; + token: string; +} + +function prependSlash(str: string): string { + if (str.length > 0 && str.charAt(0) !== '/') { + return `/${str}`; + } + return str; +} + +/** + * Validate that the user-supplied `ConnectionOptions` describe a PAT auth + * configuration and build the napi-binding's connection-options shape. + * + * M0 SCOPE: PAT only. + * - Accepts `authType: 'access-token'` and the undefined-authType default + * (which already means PAT throughout the existing driver — see + * `DBSQLClient.createAuthProvider`). + * - Rejects every other `authType` discriminant with a clear + * "M0 supports only PAT" message so callers know OAuth / Federation / + * custom providers land in M1. + * + * Throws: + * - `AuthenticationError` when the auth mode is PAT but `token` is missing + * or empty. + * - `HiveDriverError` when the auth mode is anything other than PAT. + */ +export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNativeConnectionOptions { + const { authType } = options as { authType?: string }; + + if (authType !== undefined && authType !== 'access-token') { + throw new HiveDriverError( + `SEA backend (M0) supports only PAT auth (authType: 'access-token'); ` + + `got authType: '${authType}'. Other auth modes (databricks-oauth, ` + + `token-provider, external-token, static-token, custom) will land in M1.`, + ); + } + + // PAT path — at this point `options` is structurally the access-token branch + // of `AuthOptions`, which guarantees a `token` field at the type level. We + // still defensively re-check because the public ConnectionOptions type + // permits `authType: undefined` with no token at runtime. + const { token } = options as { token?: string }; + if (typeof token !== 'string' || token.length === 0) { + throw new AuthenticationError( + 'SEA backend: a non-empty PAT must be supplied via `token` when using `authType: \'access-token\'`.', + ); + } + + return { + hostName: options.host, + httpPath: prependSlash(options.path), + token, + }; +} diff --git a/lib/sea/SeaBackend.ts b/lib/sea/SeaBackend.ts index 5815dc05..ee20a1ba 100644 --- a/lib/sea/SeaBackend.ts +++ b/lib/sea/SeaBackend.ts @@ -1,18 +1,169 @@ +// 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 IOperationBackend from '../contracts/IOperationBackend'; +import { ConnectionOptions, OpenSessionRequest } from '../contracts/IDBSQLClient'; +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 { getSeaNative, SeaNativeBinding } from './SeaNativeLoader'; +import { buildSeaConnectionOptions, SeaNativeConnectionOptions } from './SeaAuth'; + +const NOT_IMPLEMENTED_SESSION = + 'SEA session backend: method not implemented in sea-auth (M0); lands in sea-execution/sea-operation.'; + +/** + * Opaque handle to the napi binding's `Connection` class. The exact + * shape lives in `native/sea/index.d.ts` (auto-generated). We type it as + * a structural minimum here so the loader's pass-through typing doesn't + * leak into every call site. + */ +interface NativeConnection { + close(): Promise; +} + +/** + * Minimal `ISessionBackend` that wraps the napi-binding's `Connection`. + * + * For M0 (sea-auth) only `id` and `close()` are functional — they're the + * subset required to round-trip a connect-open-close cycle. Every other + * method throws a clear "not implemented in M0" `HiveDriverError`. + * + * The `id` field is currently a synthetic counter-based string; the kernel + * exposes a real session-id through a follow-on getter that + * `sea-execution` will wire through. + */ +export class SeaSessionBackend implements ISessionBackend { + private static seq = 0; -const NOT_IMPLEMENTED = 'SEA backend not implemented yet — wired in sea-napi-binding feature'; + public readonly id: string; + + private readonly connection: NativeConnection; + + constructor(connection: NativeConnection) { + this.connection = connection; + SeaSessionBackend.seq += 1; + this.id = `sea-session-${SeaSessionBackend.seq}`; + } + + /* eslint-disable @typescript-eslint/no-unused-vars */ + public async getInfo(_infoType: number): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + public async executeStatement( + _statement: string, + _options: ExecuteStatementOptions, + ): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + + public async getTypeInfo(_request: TypeInfoRequest): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + + public async getCatalogs(_request: CatalogsRequest): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + + public async getSchemas(_request: SchemasRequest): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + + public async getTables(_request: TablesRequest): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + + public async getTableTypes(_request: TableTypesRequest): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + + public async getColumns(_request: ColumnsRequest): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + + public async getFunctions(_request: FunctionsRequest): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + + public async getPrimaryKeys(_request: PrimaryKeysRequest): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + + public async getCrossReference(_request: CrossReferenceRequest): Promise { + throw new HiveDriverError(NOT_IMPLEMENTED_SESSION); + } + /* eslint-enable @typescript-eslint/no-unused-vars */ + + public async close(): Promise { + await this.connection.close(); + return Status.success(); + } +} + +/** + * M0 SeaBackend — wires PAT auth + napi `openSession` end-to-end. + * + * Connect is a no-op at this layer (the napi binding has no notion of a + * standalone "connect"; a session is opened directly). We capture the + * validated PAT options and hand them to `openSession()` on demand. + * + * Subsequent milestones (`sea-execution`, `sea-operation`) replace the + * stubbed `ISessionBackend` / `IOperationBackend` methods with real + * napi-binding calls. + */ export default class SeaBackend implements IBackend { - public async connect(): Promise { - throw new Error(NOT_IMPLEMENTED); + private nativeOptions?: SeaNativeConnectionOptions; + + private readonly native: SeaNativeBinding; + + constructor(native: SeaNativeBinding = getSeaNative()) { + this.native = native; + } + + public async connect(options: ConnectionOptions): Promise { + // Validate PAT auth + capture the napi-binding option shape. + // Any non-PAT mode (or a missing token) throws here, before we ever + // touch the native binding. + this.nativeOptions = buildSeaConnectionOptions(options); } - public async openSession(): Promise { - throw new Error(NOT_IMPLEMENTED); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async openSession(_request: OpenSessionRequest): Promise { + if (!this.nativeOptions) { + throw new HiveDriverError('SeaBackend: connect() must be called before openSession().'); + } + const connection = (await this.native.openSession(this.nativeOptions)) as NativeConnection; + return new SeaSessionBackend(connection); } public async close(): Promise { - throw new Error(NOT_IMPLEMENTED); + // Connection-level resources are owned by the session wrapper. No-op here. + this.nativeOptions = undefined; } } diff --git a/tests/integration/.mocharc.js b/tests/integration/.mocharc.js new file mode 100644 index 00000000..f7113140 --- /dev/null +++ b/tests/integration/.mocharc.js @@ -0,0 +1,11 @@ +'use strict'; + +const allSpecs = 'tests/integration/**/*.test.ts'; + +const argvSpecs = process.argv.slice(4); + +module.exports = { + spec: argvSpecs.length > 0 ? argvSpecs : allSpecs, + timeout: '300000', + require: ['ts-node/register'], +}; diff --git a/tests/integration/sea/auth-pat-e2e.test.ts b/tests/integration/sea/auth-pat-e2e.test.ts new file mode 100644 index 00000000..8bff9748 --- /dev/null +++ b/tests/integration/sea/auth-pat-e2e.test.ts @@ -0,0 +1,75 @@ +// 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'; + +/** + * 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: true, + }); + 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/unit/sea/auth-pat.test.ts b/tests/unit/sea/auth-pat.test.ts new file mode 100644 index 00000000..5476d722 --- /dev/null +++ b/tests/unit/sea/auth-pat.test.ts @@ -0,0 +1,263 @@ +// 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 { SeaNativeBinding } from '../../../lib/sea/SeaNativeLoader'; +import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; +import AuthenticationError from '../../../lib/errors/AuthenticationError'; +import HiveDriverError from '../../../lib/errors/HiveDriverError'; + +/** + * Fake napi binding that records the option object handed to `openSession` + * and returns a fake `Connection` whose `close()` we can observe. No real + * native code runs in this suite. + */ +function makeFakeBinding() { + const calls: Array<{ method: string; args: unknown[] }> = []; + + const fakeConnection = { + async executeStatement() { + throw new Error('not used in this test'); + }, + async close() { + calls.push({ method: 'connection.close', args: [] }); + }, + }; + + const binding: SeaNativeBinding = { + version() { + return 'fake-binding'; + }, + async openSession(opts: { hostName: string; httpPath: string; token: string }) { + calls.push({ method: 'openSession', args: [opts] }); + return fakeConnection as unknown; + }, + Connection: function FakeConnection() {} as unknown as Function, + Statement: function FakeStatement() {} as unknown as Function, + }; + + return { binding, calls }; +} + +describe('SeaAuth + SeaBackend — PAT auth flow', () => { + 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', + 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.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('rejects OAuth with a clear M0-scope error', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw( + HiveDriverError, + /M0\) supports only PAT.*databricks-oauth.*M1/, + ); + }); + + it('rejects token-provider with a clear M0-scope error', () => { + const opts: ConnectionOptions = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'token-provider', + tokenProvider: { getToken: async () => 'tok' } as unknown as ConnectionOptions extends infer T + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + : never, + }; + + expect(() => buildSeaConnectionOptions(opts)).to.throw(HiveDriverError, /token-provider.*M1/); + }); + + 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, /M0\) supports only PAT/); + } + }); + }); + + describe('SeaBackend.connect + openSession', () => { + it('resolves on a valid PAT options object and round-trips through the napi binding', async () => { + const { binding, calls } = makeFakeBinding(); + const backend = new SeaBackend(binding); + + await backend.connect({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: 'dapi-fake-pat', + }); + + const session = await backend.openSession({}); + expect(session).to.exist; + expect(session.id).to.match(/^sea-session-\d+$/); + + 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', + token: 'dapi-fake-pat', + }); + + // Round-trip close. + const status = await session.close(); + expect(status.isSuccess).to.equal(true); + expect(calls[1].method).to.equal('connection.close'); + + await backend.close(); + }); + + it('rejects connect() when token is missing with AuthenticationError', async () => { + const { binding, calls } = makeFakeBinding(); + const backend = new SeaBackend(binding); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const opts = { + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'access-token', + } as any; + + let caught: unknown; + try { + await backend.connect(opts); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(AuthenticationError); + expect(calls).to.have.lengthOf(0); + }); + + it('rejects connect() for OAuth with the M0-scope error', async () => { + const { binding, calls } = makeFakeBinding(); + const backend = new SeaBackend(binding); + + let caught: unknown; + try { + await backend.connect({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + }); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(HiveDriverError); + expect((caught as Error).message).to.match(/M0\) supports only PAT/); + expect(calls).to.have.lengthOf(0); + }); + + it('throws when openSession() is called before connect()', async () => { + const { binding } = makeFakeBinding(); + const backend = new SeaBackend(binding); + + let caught: unknown; + try { + await backend.openSession({}); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(HiveDriverError); + expect((caught as Error).message).to.match(/connect\(\) must be called/); + }); + + it('stubbed session methods reject with a clear M0-scope error', async () => { + const { binding } = makeFakeBinding(); + const backend = new SeaBackend(binding); + + await backend.connect({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: 'dapi-fake-pat', + }); + const session = await backend.openSession({}); + + let caught: unknown; + try { + await session.executeStatement('SELECT 1', {}); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(HiveDriverError); + expect((caught as Error).message).to.match(/not implemented in sea-auth \(M0\)/); + }); + }); +});