Skip to content

Commit e9131ae

Browse files
committed
sea-auth-u2m: round-5 fixup — JSDoc selector contract, defense-in-depth test, message mutation safety, class-pin simplification
- DA4-1 (HIGH): rewrite buildSeaConnectionOptions function-level JSDoc to describe the id-keyed flow selector (round-4 NF3-2 fix); the block-comment was updated but the public-API JSDoc was missed. - DA4-2 (MEDIUM): add test for the SeaAuth.ts:201-210 defense-in-depth U2M+id rejection branch (zero coverage after round-4 flipped the three tests that previously exercised it). - DA4-3a (MEDIUM): wrap err.message mutation on corrupted-envelope path in try/catch; fall back to a fresh HiveDriverError if the message descriptor is non-writable (defensive for future napi-rs versions; mutation preserves napi-side stack on the common path). - DA4-3b (MEDIUM): delete redundant constructor.name check from class-pin test; instanceof AuthenticationError is sufficient because instanceof is a one-way subclass check. Fix the comment that incorrectly claimed instanceof couldn't distinguish. - LE4-1 (MEDIUM): add this.name = 'AuthenticationError' constructor to the AuthenticationError class so err.name / err.toString() identify the subclass (3 lines; doesn't extend to sibling error classes in this PR). - DA4-4 (LOW): drop "reserved for future RetryError mappings" from three SeaErrorMapping.ts doc-comments — no kernel ErrorCode maps to RetryError and there's no design doc proposing one. - LE4-2 (LOW): unify the class-pin test to chai's idiomatic .to.throw(Class, /regex/) form, matching the rest of the suite. - LE4-4 (LOW): one-line comment justifying mutate-vs-clone choice on the corrupted-envelope path. Skipped per disposition: LE4-3 (idIsBlank/secretIsBlank symmetry — LE-4 own recommended leave-as-is). Deferred (carries over from round-3): NF3-1 kernel sub-classification (Phase 2 kernel work), NF3-4 e2e kernel-error-code assertion (blocked on NF3-1), NF3-5 path-dep SHA pin (resolves on kernel publish), LE3-1..3 SeaErrorMapping decoder polish (Phase 2 bundle). Co-authored-by: Isaac
1 parent a97b96e commit e9131ae

4 files changed

Lines changed: 73 additions & 32 deletions

File tree

lib/errors/AuthenticationError.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
import HiveDriverError from './HiveDriverError';
22

3-
export default class AuthenticationError extends HiveDriverError {}
3+
export default class AuthenticationError extends HiveDriverError {
4+
constructor(message?: string) {
5+
super(message);
6+
this.name = 'AuthenticationError';
7+
}
8+
}

lib/sea/SeaAuth.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,14 @@ export function isBlankOrReserved(s: string): boolean {
104104
* - OAuth M2M: `authType: 'databricks-oauth'` + `oauthClientId` +
105105
* `oauthClientSecret`. Kernel handles OIDC discovery, client_credentials
106106
* exchange, and re-auth on expiry internally.
107-
* - OAuth U2M: `authType: 'databricks-oauth'` + NO `oauthClientSecret`.
108-
* Kernel runs the PKCE auth-code dance (opens a browser, listens on
109-
* localhost:8030, exchanges the code, persists to
110-
* `~/.config/databricks-sql-kernel/oauth/{sha256}.json`). The flow
111-
* selector matches thrift at `DBSQLClient.ts:143` —
112-
* `oauthClientSecret defined ? M2M : U2M`.
107+
* - OAuth U2M: `authType: 'databricks-oauth'` + NO `oauthClientId` and
108+
* NO `oauthClientSecret`. Kernel runs the PKCE auth-code dance (opens
109+
* a browser, listens on localhost:8030, exchanges the code, persists
110+
* to `~/.config/databricks-sql-kernel/oauth/{sha256}.json`). The flow
111+
* selector keys off `oauthClientId` presence: present → M2M, absent →
112+
* U2M. (Round-4 NF3-2 fix; previously secret-keyed — that variant
113+
* routed a typo'd-secret M2M call to the U2M arm and swallowed the
114+
* actionable error.) Mirrors thrift's intent at `DBSQLClient.ts:143`.
113115
*
114116
* Out of scope on the OAuth paths (rejected with a clear error):
115117
* - `azureTenantId` / `useDatabricksOAuthInAzure` → Microsoft Entra
@@ -188,7 +190,12 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
188190
// user who set an id but typoed/forgot the secret gets the M2M
189191
// "secret is required" error instead of a U2M error that hides
190192
// their actual intent. The U2M arm still defends against an id
191-
// sneaking through (e.g. caller bypasses shape inference).
193+
// sneaking through: fires only when `oauthClientId` is provided as
194+
// a blank-reserved literal (e.g., whitespace, `"null"`, `"undefined"`)
195+
// alongside an absent/blank secret — both `idIsBlank` and
196+
// `secretIsBlank` are true so U2M wins routing, but the caller's
197+
// intent to use U2M with a partially-set id is ambiguous and
198+
// rejected explicitly.
192199
const idIsBlank =
193200
oauth.oauthClientId === undefined ||
194201
(typeof oauth.oauthClientId === 'string' && isBlankOrReserved(oauth.oauthClientId));

lib/sea/SeaErrorMapping.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ export type KernelErrorCode =
6060
* `errorCode: enum` field, and `DBSQLOperation.ts:209` switches on it
6161
* (`err.errorCode === OperationStateErrorCode.Canceled`). Top-level
6262
* defineProperty would clobber that enum with a kernel string and break
63-
* cancel/close detection. (`RetryError.errorCode` is the same shape and
64-
* is reserved here for future kernel→`RetryError` mappings.)
63+
* cancel/close detection.
6564
*/
6665
export interface KernelMetadata {
6766
errorCode?: string;
@@ -76,7 +75,7 @@ export interface KernelMetadata {
7675
* exposed at the top level (no collision in the existing driver error
7776
* tree); the remaining envelope fields live under a `kernelMetadata`
7877
* namespace to avoid clobbering pre-existing `errorCode` semantics on
79-
* `OperationStateError` (and, reserved for future use, `RetryError`).
78+
* `OperationStateError`.
8079
*/
8180
export interface ErrorWithSqlState extends Error {
8281
sqlState?: string;
@@ -211,9 +210,7 @@ function buildKernelMetadata(parsed: Record<string, unknown>): KernelMetadata {
211210
* envelope fields under a single non-enumerable `kernelMetadata`
212211
* namespace. Namespacing avoids the collision with
213212
* `OperationStateError.errorCode` (an enum already switched on at the
214-
* JS layer — see `DBSQLOperation.ts:209`). `RetryError.errorCode`
215-
* shares the shape and is reserved for future kernel→`RetryError`
216-
* mappings.
213+
* JS layer — see `DBSQLOperation.ts:209`).
217214
* - Binding-side error (e.g. `napi::Error::new(InvalidArg, "openSession:
218215
* \`token\` is required for the requested auth mode")` produced by
219216
* the binding's own validation): returned unchanged. These don't
@@ -243,8 +240,20 @@ export function decodeNapiKernelError(err: unknown): Error {
243240
// `__databricks_error__:` prefix; it's a binding/JS-side framing
244241
// marker, not user-actionable, and leaking it makes the message
245242
// confusing to operators triaging a malformed kernel response.
246-
err.message = jsonStr;
247-
return err;
243+
//
244+
// Mutate in place when possible so the napi-binding's original
245+
// stack survives — that stack is the only useful triage signal on
246+
// a malformed-envelope path (where did a sentinel-prefixed
247+
// non-JSON message come from?). Fall back to a fresh
248+
// `HiveDriverError` only if a future napi-rs revision makes
249+
// `Error.message` non-writable (no such guarantee today, but the
250+
// descriptor contract is implementation-defined).
251+
try {
252+
err.message = jsonStr;
253+
return err;
254+
} catch {
255+
return new HiveDriverError(jsonStr);
256+
}
248257
}
249258

250259
if (

tests/unit/sea/auth-edge-cases.test.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,17 @@ describe('SeaAuth — edge cases (input validation + ambiguity)', () => {
157157
);
158158
});
159159

160-
// Round-4 NF3-2: pin the exact class — must be `AuthenticationError`,
161-
// not the bare `HiveDriverError` superclass. The round-3 NF-N3 fix
162-
// swapped this silently by routing M2M-with-empty-secret through the
163-
// U2M arm, which raised a plain `HiveDriverError`. Guard against that
164-
// regression by pinning the constructor name (since
165-
// `AuthenticationError extends HiveDriverError`, `instanceof` alone
166-
// can't distinguish the two).
160+
// Round-4 NF3-2: pin the exact class against the round-3 NF-N3
161+
// regression where M2M-with-empty-secret was routed through the U2M
162+
// arm and raised a bare `HiveDriverError`. `instanceof
163+
// AuthenticationError` correctly returns `false` for a bare
164+
// `HiveDriverError` instance (instanceof is a one-way subclass
165+
// check), so the subclass check IS sufficient to catch the
166+
// regression. We don't add an `error.name` or `constructor.name`
167+
// belt — the former requires `this.name` on the subclass (LE4-1
168+
// handles that separately for downstream-consumer benefit, not for
169+
// this test), and the latter is bundler-fragile (terser/esbuild
170+
// strip class names without `keep_classnames`).
167171
it('M2M-with-empty-secret throws AuthenticationError, not bare HiveDriverError (class pin)', () => {
168172
const opts: ConnectionOptions = {
169173
host: 'example.cloud.databricks.com',
@@ -173,15 +177,31 @@ describe('SeaAuth — edge cases (input validation + ambiguity)', () => {
173177
oauthClientSecret: '',
174178
};
175179

176-
let caught: unknown;
177-
try {
178-
buildSeaConnectionOptions(opts);
179-
} catch (e) {
180-
caught = e;
181-
}
182-
expect(caught).to.be.instanceOf(AuthenticationError);
183-
expect((caught as Error).constructor.name).to.equal('AuthenticationError');
184-
expect((caught as Error).message).to.match(/oauthClientSecret.*non-empty.*OAuth M2M/);
180+
expect(() => buildSeaConnectionOptions(opts)).to.throw(
181+
AuthenticationError,
182+
/oauthClientSecret.*non-empty.*OAuth M2M/,
183+
);
184+
});
185+
186+
// Round-5 DA4-2: the round-3 → round-4 test flips left the U2M-arm
187+
// defense-in-depth U2M+id rejection without coverage. It's still
188+
// reachable: when `oauthClientId` is a blank-reserved literal
189+
// (whitespace, `"null"`, `"undefined"`) AND `oauthClientSecret` is
190+
// absent/blank, BOTH `idIsBlank` and `secretIsBlank` are true so
191+
// U2M wins routing — but a non-undefined id signals ambiguity that
192+
// U2M cannot honor (the kernel hardcodes `databricks-cli`).
193+
it('routes a whitespace oauthClientId with no oauthClientSecret to the U2M defense-in-depth rejection', () => {
194+
const opts: ConnectionOptions = {
195+
host: 'example.cloud.databricks.com',
196+
path: '/sql/1.0/warehouses/abc',
197+
authType: 'databricks-oauth',
198+
oauthClientId: ' ',
199+
} as unknown as ConnectionOptions;
200+
201+
expect(() => buildSeaConnectionOptions(opts)).to.throw(
202+
HiveDriverError,
203+
/oauthClientId.*not supported on the OAuth U2M flow/,
204+
);
185205
});
186206
});
187207

0 commit comments

Comments
 (0)