diff --git a/src/featureFlag/FeatureFlagProvider.ts b/src/featureFlag/FeatureFlagProvider.ts index 15379bd2..988000a6 100644 --- a/src/featureFlag/FeatureFlagProvider.ts +++ b/src/featureFlag/FeatureFlagProvider.ts @@ -84,13 +84,13 @@ export class FeatureFlagProvider implements Closeable { this.log(); } - @Measure({ name: 'getFeatureFlags' }) + @Measure({ name: 'getFeatureFlags', captureErrorAttributes: true }) private async getFeatureFlags(env: string): Promise { try { return await this.getLatestFeatureFlags(env); } catch (error) { if (isClientNetworkError(error)) { - this.telemetry.count('getFeatureFlags.clientNetworkError', 1); + this.telemetry.error('getFeatureFlags.clientNetworkError', error); log.info('Skipping feature flag refresh due to client network error'); return this.config; } diff --git a/src/schema/GetSamSchemaTask.ts b/src/schema/GetSamSchemaTask.ts index a9d14459..e990ed5b 100644 --- a/src/schema/GetSamSchemaTask.ts +++ b/src/schema/GetSamSchemaTask.ts @@ -45,7 +45,7 @@ export class GetSamSchemaTask extends GetSchemaTask { this.logger.info(`${resourceSchemas.size} SAM schemas downloaded and saved`); } catch (error) { if (isClientNetworkError(error)) { - this.telemetry.count('getSchemas.clientNetworkError', 1); + this.telemetry.error('getSchemas.clientNetworkError', error); this.logger.info('Skipping SAM schemas due to client network error'); return; } diff --git a/src/schema/GetSchemaTask.ts b/src/schema/GetSchemaTask.ts index c3fb77fa..aad01361 100644 --- a/src/schema/GetSchemaTask.ts +++ b/src/schema/GetSchemaTask.ts @@ -73,7 +73,9 @@ export class GetPublicSchemaTask extends GetSchemaTask { this.logger.info(`${schemas.length} public schemas downloaded for ${this.region} and saved`); } catch (error) { if (isClientNetworkError(error)) { - this.telemetry.count('getSchemas.clientNetworkError', 1, { attributes: { region: this.region } }); + this.telemetry.error('getSchemas.clientNetworkError', error, undefined, { + attributes: { region: this.region }, + }); this.logger.info(`Skipping public schemas for ${this.region} due to client network error`); return; } diff --git a/src/telemetry/ScopedTelemetry.ts b/src/telemetry/ScopedTelemetry.ts index 2ea288ff..18ffd392 100644 --- a/src/telemetry/ScopedTelemetry.ts +++ b/src/telemetry/ScopedTelemetry.ts @@ -11,7 +11,7 @@ import { ValueType, } from '@opentelemetry/api'; import { Closeable } from '../utils/Closeable'; -import { errorAttributes, errorType } from '../utils/Errors'; +import { errorAttributes, errorType } from '../utils/ErrorStackInfo'; import { hasSuppressFault } from '../utils/FaultSuppression'; import { typeOf } from '../utils/TypeCheck'; import { TelemetryContext } from './TelemetryContext'; @@ -56,19 +56,12 @@ export class ScopedTelemetry implements Closeable { } error(name: string, error: unknown, origin?: 'uncaughtException' | 'unhandledRejection', config?: MetricConfig) { - if (config?.captureErrorAttributes) { - config.attributes = { - ...config.attributes, - ...errorType(error), - ...errorAttributes(error, origin), - }; - } else if (config?.captureErrorType) { - config.attributes = { - ...config.attributes, - ...errorType(error), - }; - } - this.count(name, 1, config); + const attributes: Attributes = { + ...config?.attributes, + ...errorType(error), + ...(config?.captureErrorAttributes ? errorAttributes(error, origin) : {}), + }; + this.count(name, 1, { ...config, attributes }); } registerGaugeProvider(name: string, provider: () => number, config?: MetricConfig): void { diff --git a/src/utils/AwsErrorMapper.ts b/src/utils/AwsErrorMapper.ts index 48023218..99e533c1 100644 --- a/src/utils/AwsErrorMapper.ts +++ b/src/utils/AwsErrorMapper.ts @@ -1,5 +1,5 @@ import { ErrorCodes, ResponseError } from 'vscode-languageserver'; -import { extractErrorMessage } from './Errors'; +import { CLIENT_NETWORK_ERROR_CODES, extractErrorMessage } from './Errors'; import { createOnlineFeatureError, OnlineFeatureErrorCode } from './OnlineFeatureError'; type AwsError = { @@ -21,14 +21,9 @@ const CREDENTIAL_ERROR_NAMES = new Set([ 'ExpiredTokenException', ]); -const NETWORK_ERROR_NAMES = new Set([ - 'NetworkingError', - 'TimeoutError', - 'ENOTFOUND', - 'ECONNREFUSED', - 'ETIMEDOUT', - 'ECONNRESET', -]); +const AWS_NETWORK_ERROR_NAMES: ReadonlySet = new Set(['NetworkingError', 'TimeoutError']); + +const NETWORK_ERROR_NAMES: ReadonlySet = new Set([...AWS_NETWORK_ERROR_NAMES, ...CLIENT_NETWORK_ERROR_CODES]); const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); diff --git a/src/utils/ErrorStackInfo.ts b/src/utils/ErrorStackInfo.ts index db898020..ded2c9da 100644 --- a/src/utils/ErrorStackInfo.ts +++ b/src/utils/ErrorStackInfo.ts @@ -1,28 +1,66 @@ -import { LoggerFactory } from '../telemetry/LoggerFactory'; +import { Attributes } from '@opentelemetry/api'; +import { classifyAwsError } from './AwsErrorMapper'; +import { extractRootCause, extractErrorCode, extractHttpStatus } from './Errors'; +import { sanitizeMessage } from './Sanitizer'; -let errorStackInfo: string[] | undefined; +/** + * Best effort extraction of location of exception based on stack trace + */ +export function extractLocationFromStack(stack?: string): Record { + if (!stack) return {}; -export function determineSensitiveInfo(): string[] { - if (errorStackInfo !== undefined) { - return errorStackInfo; + const lines = sanitizeMessage(stack).split('\n'); + + if (lines.length === 0) { + return {}; } - try { - errorStackInfo = __dirname - .replaceAll('\\\\', '/') - .replaceAll('\\', '/') - .split(/[/:]/) - .map((x) => { - return x.trim(); - }) - .filter((x) => { - return x.length > 1; - }); - } catch (err) { - LoggerFactory.getLogger('SensitiveInfo').warn(err, 'Cannot get __dirname'); - errorStackInfo = []; + return { + ['error.message']: lines[0], + ['error.stack']: lines.slice(1).join('\n'), + }; +} + +export function errorAttributes(error: unknown, origin?: 'uncaughtException' | 'unhandledRejection'): Attributes { + const location = error instanceof Error ? extractLocationFromStack(error.stack) : {}; + const cause = extractRootCause(error); + const causeLocation = cause ? extractLocationFromStack(cause.stack) : {}; + + return { + 'error.origin': origin ?? 'Unknown', + ...location, + ...(causeLocation['error.message'] !== undefined && { 'error.cause.message': causeLocation['error.message'] }), + ...(causeLocation['error.stack'] !== undefined && { 'error.cause.stack': causeLocation['error.stack'] }), + }; +} + +export function errorType(error: unknown): Attributes { + const type = error instanceof Error ? error.name : typeof error; + const code = extractErrorCode(error); + + const cause = extractRootCause(error); + const status = extractHttpStatus(error); + const causeStatus = cause ? extractHttpStatus(cause) : undefined; + + const awsClassification = classifyAwsError(error); + const awsAttr: Record = {}; + if (awsClassification.category !== 'unknown') { + awsAttr['error.aws.category'] = sanitizeMessage(awsClassification.category); } + if (awsClassification.httpStatus) { + awsAttr['error.aws.http.status'] = sanitizeMessage(`${awsClassification.httpStatus}`); + } + + return { + 'error.type': sanitizeMessage(type), + 'error.code': sanitizeMessage(code ?? 'Unknown'), - errorStackInfo = [__dirname.trim(), ...errorStackInfo]; - return errorStackInfo; + ...(status !== undefined && { 'error.http.status': status }), + ...(cause && { + 'error.cause.type': sanitizeMessage(cause.name), + 'error.cause.code': sanitizeMessage(extractErrorCode(cause) ?? 'Unknown'), + ...(causeStatus !== undefined && { 'error.cause.http.status': sanitizeMessage(`${causeStatus}`) }), + }), + ...awsAttr, + }; } diff --git a/src/utils/Errors.ts b/src/utils/Errors.ts index 1c6b8b47..645c8333 100644 --- a/src/utils/Errors.ts +++ b/src/utils/Errors.ts @@ -1,32 +1,58 @@ -import { Attributes } from '@opentelemetry/api'; import { ErrorCodes, ResponseError } from 'vscode-languageserver'; -import { determineSensitiveInfo } from './ErrorStackInfo'; import { toString } from './String'; -const CLIENT_NETWORK_ERROR_PATTERNS = [ - 'unable to get local issuer certificate', - 'self signed certificate', - 'unable to verify the first certificate', - 'certificate has expired', - 'does not match certificate', - 'WRONG_VERSION_NUMBER', - 'ECONNRESET', - 'ETIMEDOUT', - 'ECONNREFUSED', - 'ENOTFOUND', - 'EAI_AGAIN', - 'ECONNABORTED', - 'EBADF', - 'socket hang up', - 'network socket disconnected', - 'TOO_MANY_REDIRECTS', - 'Parse Error: Expected HTTP', - 'status code 407', +export const CLIENT_NETWORK_ERROR_CODES: ReadonlySet = new Set([ + 'ECONNRESET', // Peer reset the connection mid-stream + 'ETIMEDOUT', // Operation exceeded its timeout + 'ECONNREFUSED', // Server actively refused (port closed / not listening) + 'ENOTFOUND', // DNS resolution failed + 'EAI_AGAIN', // Transient DNS resolver failure (retry-eligible) + 'ECONNABORTED', // Local socket aborted (often an axios timeout) + 'EPIPE', // Wrote to a socket the peer already closed + 'EHOSTUNREACH', // No route to host (firewall / unreachable) + 'ENETUNREACH', // Network unreachable (offline / interface down) + 'NGHTTP2_REFUSED_STREAM', // HTTP/2 server refused a new stream (transient) +]); + +const CLIENT_NETWORK_ERROR_MESSAGE_PATTERNS = [ + 'unable to get local issuer certificate', // Cert chain root not in client trust store + 'self signed certificate', // Peer presented a self-signed certificate + 'unable to verify the first certificate', // Incomplete server cert chain + 'certificate has expired', // Server cert past validity window + 'does not match certificate', // Hostname / SAN mismatch + 'WRONG_VERSION_NUMBER', // TLS version mismatch (often plaintext on a TLS port) + 'socket hang up', // Peer closed connection before responding + 'network socket disconnected', // Underlying socket dropped mid-request + 'TOO_MANY_REDIRECTS', // Client exceeded redirect limit (e.g. ERR_FR_TOO_MANY_REDIRECTS) + 'Parse Error: Expected HTTP', // Non-HTTP response on HTTP port (proxy / wrong protocol) + 'status code 407', // Proxy Authentication Required ]; export function isClientNetworkError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return CLIENT_NETWORK_ERROR_PATTERNS.some((pattern) => message.toLowerCase().includes(pattern.toLowerCase())); + const parts: string[] = [extractErrorMessage(error)]; + + if (typeof error === 'object' && error !== null) { + const { code, name } = error as { code?: unknown; name?: unknown }; + if (typeof code === 'string') { + parts.push(code); + } + if (typeof name === 'string') { + parts.push(name); + } + } + + const haystack = parts.join(' ').toLowerCase(); + + // Match canonical codes (errno-style) and free-form message patterns + for (const code of CLIENT_NETWORK_ERROR_CODES) { + if (haystack.includes(code.toLowerCase())) { + return true; + } + } + + return CLIENT_NETWORK_ERROR_MESSAGE_PATTERNS.some((pattern) => { + return haystack.includes(pattern.toLowerCase()); + }); } export function extractStatusReason(error: unknown): string | undefined { @@ -60,62 +86,74 @@ export function handleLspError(error: unknown, contextMessage: string): never { throw new ResponseError(ErrorCodes.InternalError, `${contextMessage}: ${extractErrorMessage(error)}`); } -/** - * Best effort extraction of location of exception based on stack trace - */ -export function extractLocationFromStack(stack?: string): Record { - if (!stack) return {}; - - const lines = stack - .trim() - .split('\n') - .map((line) => { - let newLine = line.trim(); - for (const word of determineSensitiveInfo()) { - if (word !== 'aws' && word !== 'cloudformation-languageserver') { - newLine = newLine.replaceAll(word, '[*]'); - } - } - - return newLine.replaceAll('\\\\', '/').replaceAll('\\', '/'); - }) - .map((line) => { - return sanitizeErrorMessage(line); - }); - - if (lines.length === 0) { - return {}; - } - - return { - ['error.message']: lines[0], - ['error.stack']: lines.slice(1).join('\n'), - }; -} +export function extractRootCause(error: unknown): Error | undefined { + if (error === null || typeof error !== 'object') { + return undefined; + } -function sanitizeErrorMessage(message: string): string { - return message - .replaceAll(/arn:aws[^:\s]*:\S+\d{12}\S*/gi, 'arn:aws:') - .replaceAll(/\b\d{12}\b/g, ''); + const errorAs = error as { commitError?: unknown; cause?: unknown }; + + if (errorAs.commitError instanceof Error) { + return errorAs.commitError; + } + + if (errorAs.cause instanceof Error) { + return errorAs.cause; + } + + return undefined; } -export function errorAttributes(error: unknown, origin?: 'uncaughtException' | 'unhandledRejection'): Attributes { - const location = error instanceof Error ? extractLocationFromStack(error.stack) : {}; +export function extractErrorCode(error: unknown): string | undefined { + if (error === null || typeof error !== 'object') { + return undefined; + } + + const { code, Code, CODE, errno } = error as { code?: unknown; Code?: unknown; CODE?: unknown; errno?: number }; - return { - 'error.origin': origin ?? 'Unknown', - ...location, - }; + if (typeof code === 'string') { + return code; + } + + if (typeof Code === 'string') { + return Code; + } + + if (typeof CODE === 'string') { + return CODE; + } + + if (typeof errno === 'number') { + return `${errno}`; + } + + return undefined; } -export function errorType(error: unknown): Attributes { - const type = error instanceof Error ? error.name : typeof error; - const code = error !== null && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : undefined; +export function extractHttpStatus(error: unknown): number | undefined { + if (error === null || typeof error !== 'object') { + return undefined; + } - return { - 'error.type': type, - 'error.code': code ?? 'Unknown', + const candidate = error as { + $metadata?: { httpStatusCode?: number }; + response?: { status?: number }; + status?: number; }; + + if (typeof candidate.$metadata?.httpStatusCode === 'number') { + return candidate.$metadata?.httpStatusCode; + } + + if (typeof candidate.response?.status === 'number') { + return candidate.response?.status; + } + + if (typeof candidate.status === 'number') { + return candidate.status; + } + + return undefined; } export class DoesNotExist extends Error { diff --git a/src/utils/FaultSuppression.ts b/src/utils/FaultSuppression.ts index 1620cec0..13e030fb 100644 --- a/src/utils/FaultSuppression.ts +++ b/src/utils/FaultSuppression.ts @@ -1,19 +1,24 @@ import { isClientError } from './AwsErrorMapper'; +import { isClientNetworkError } from './Errors'; -export interface Suppressible { - suppressFault?: true; +export const SUPPRESS_FAULT = Symbol('SUPPRESS_FAULT'); + +interface Suppressible { + [SUPPRESS_FAULT]?: true; } -export function markSuppressFault(error: Error): void { - (error as Error & Suppressible).suppressFault = true; +export function markSuppressFault(error: unknown): void { + if (typeof error === 'object' && error !== null) { + (error as Suppressible)[SUPPRESS_FAULT] = true; + } } export function markIfClientError(error: unknown): void { - if (error instanceof Error && isClientError(error)) { + if (typeof error === 'object' && error !== null && (isClientError(error) || isClientNetworkError(error))) { markSuppressFault(error); } } -export function hasSuppressFault(error: unknown): error is Suppressible { - return (error as Suppressible | null)?.suppressFault === true; +export function hasSuppressFault(error: unknown): boolean { + return typeof error === 'object' && error !== null && (error as Suppressible)[SUPPRESS_FAULT] === true; } diff --git a/src/utils/RemoteDownload.ts b/src/utils/RemoteDownload.ts index 537ff31c..20164bbe 100644 --- a/src/utils/RemoteDownload.ts +++ b/src/utils/RemoteDownload.ts @@ -21,6 +21,7 @@ export async function downloadFile(url: string): Promise { url: url, responseType: 'arraybuffer', proxy: getProxyConfig(), + maxRedirects: 7, }); LoggerFactory.getLogger('Remote').info(`Fetching ${url}`); @@ -33,6 +34,7 @@ export async function downloadJson(url: string): Promise { method: 'get', url: url, proxy: getProxyConfig(), + maxRedirects: 7, }); return response.data; diff --git a/src/utils/Sanitizer.ts b/src/utils/Sanitizer.ts new file mode 100644 index 00000000..ccb296bb --- /dev/null +++ b/src/utils/Sanitizer.ts @@ -0,0 +1,80 @@ +import { homedir, hostname } from 'os'; +import { LoggerFactory } from '../telemetry/LoggerFactory'; + +let sensitiveWords: string[] | undefined; + +/** + * Returns a list of strings that callers should treat as sensitive and redact from + * any text — typically error stack traces — before it leaves the process (e.g. is + * sent to telemetry or logged externally). + * + * The list is built from the language server's install location and the local machine + * identity, since these values commonly appear in Node.js stack traces and may contain + * personally identifiable information such as a username embedded in a home directory. + * + * Composition of the returned array: + * - The full trimmed value of `__dirname` (the entire install path). + * - Each non-trivial path segment of `__dirname`, obtained by normalizing + * backslashes to forward slashes, splitting on `/` and `:`, trimming each segment, + * and discarding segments of length 0 or 1 (which would over-match if redacted). + * - The machine `hostname()`. + * - The current user's `homedir()`. + * + * If reading `__dirname` throws (e.g. in an exotic runtime), the segment list falls + * back to `[hostname(), homedir()]` and a warning is logged via + * {@link LoggerFactory}. + * + * @returns An array of strings to redact from outbound diagnostic text. Callers should + * treat the array as read-only. + + */ +export function sensitiveInfo(): string[] { + if (sensitiveWords !== undefined) { + return sensitiveWords; + } + + try { + sensitiveWords = __dirname + .replaceAll('\\\\', '/') + .replaceAll('\\', '/') + .split(/[/:]/) + .map((x) => { + return x.trim(); + }) + .filter((x) => { + return x.length > 1; + }); + } catch (err) { + LoggerFactory.getLogger('SensitiveInfo').warn(err, 'Cannot get __dirname'); + sensitiveWords = [hostname(), homedir()]; + } + + sensitiveWords = [__dirname.trim(), ...sensitiveWords, hostname(), homedir()]; + return sensitiveWords; +} + +export function sanitizeMessage(message: string): string { + if (!message) { + return message; + } + + return message + .trim() + .split('\n') + .map((line) => { + let newLine = line.trim(); + for (const word of sensitiveInfo()) { + if (word !== 'aws' && word !== 'cloudformation-languageserver') { + newLine = newLine.replaceAll(word, '[*]'); + } + } + + return newLine.replaceAll('\\\\', '/').replaceAll('\\', '/'); + }) + .map((line) => { + return line + .replaceAll(/arn:aws[^:\s]*:\S+\d{12}\S*/gi, 'arn:aws:') + .replaceAll(/\b\d{12}\b/g, ''); + }) + .join('\n'); +} diff --git a/tst/unit/featureFlag/FeatureFlagProvider.test.ts b/tst/unit/featureFlag/FeatureFlagProvider.test.ts index ab1679e7..9e4503fe 100644 --- a/tst/unit/featureFlag/FeatureFlagProvider.test.ts +++ b/tst/unit/featureFlag/FeatureFlagProvider.test.ts @@ -112,10 +112,10 @@ describe('FeatureFlagProvider', () => { describe('client network error handling', () => { let provider: FeatureFlagProvider; - let countSpy: ReturnType; + let errorSpy: ReturnType; beforeEach(() => { - countSpy = vi.spyOn(ScopedTelemetry.prototype, 'count'); + errorSpy = vi.spyOn(ScopedTelemetry.prototype, 'error'); }); afterEach(() => { @@ -124,6 +124,7 @@ describe('FeatureFlagProvider', () => { }); it('handles client network errors gracefully without throwing', async () => { + const error = new Error('self signed certificate in certificate chain'); provider = new FeatureFlagProvider( () => Promise.reject(new Error('self signed certificate in certificate chain')), join(__dirname, '..', '..', '..', 'assets', 'featureFlag', 'alpha.json'), @@ -131,7 +132,7 @@ describe('FeatureFlagProvider', () => { // Trigger refresh by accessing internal method await expect((provider as any).getFeatureFlags('alpha')).resolves.not.toThrow(); - expect(countSpy).toHaveBeenCalledWith('getFeatureFlags.clientNetworkError', 1); + expect(errorSpy).toHaveBeenCalledWith('getFeatureFlags.clientNetworkError', error); }); it('rethrows non-client network errors', async () => { diff --git a/tst/unit/telemetry/ScopedTelemetry.test.ts b/tst/unit/telemetry/ScopedTelemetry.test.ts index 1a127db8..392528a4 100644 --- a/tst/unit/telemetry/ScopedTelemetry.test.ts +++ b/tst/unit/telemetry/ScopedTelemetry.test.ts @@ -82,11 +82,12 @@ describe('ScopedTelemetry', () => { 1, expect.objectContaining({ 'error.type': 'TypeError', + 'error.message': expect.any(String), }), ); }); - it('should not capture error attributes on fault when config is undefined', () => { + it('captures structured error type by default but not message or stack', () => { const mockCounter = { add: vi.fn() }; mockMeter.createCounter.mockReturnValue(mockCounter); @@ -95,13 +96,17 @@ describe('ScopedTelemetry', () => { }); expect(() => scopedTelemetry.measure('test', fn)).toThrow('test error'); + expect(mockCounter.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ 'error.type': 'Error', 'error.code': 'Unknown' }), + ); expect(mockCounter.add).not.toHaveBeenCalledWith( 1, - expect.objectContaining({ 'error.type': expect.any(String) }), + expect.objectContaining({ 'error.message': expect.any(String) }), ); }); - it('should not capture error attributes on fault when captureErrorAttributes is false', () => { + it('captures structured error type but not message or stack when captureErrorAttributes is false', () => { const mockCounter = { add: vi.fn() }; mockMeter.createCounter.mockReturnValue(mockCounter); @@ -110,9 +115,10 @@ describe('ScopedTelemetry', () => { }); expect(() => scopedTelemetry.measure('test', fn, { captureErrorAttributes: false })).toThrow('test error'); + expect(mockCounter.add).toHaveBeenCalledWith(1, expect.objectContaining({ 'error.type': 'Error' })); expect(mockCounter.add).not.toHaveBeenCalledWith( 1, - expect.objectContaining({ 'error.type': expect.any(String) }), + expect.objectContaining({ 'error.message': expect.any(String) }), ); }); }); @@ -155,24 +161,29 @@ describe('ScopedTelemetry', () => { 1, expect.objectContaining({ 'error.type': 'ReferenceError', + 'error.message': expect.any(String), }), ); }); - it('should not capture error attributes on async fault when config is undefined', async () => { + it('captures structured error type by default on async fault but not message or stack', async () => { const mockCounter = { add: vi.fn() }; mockMeter.createCounter.mockReturnValue(mockCounter); const fn = vi.fn(() => Promise.reject(new Error('test error'))); await expect(scopedTelemetry.measureAsync('test', fn)).rejects.toThrow('test error'); + expect(mockCounter.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ 'error.type': 'Error', 'error.code': 'Unknown' }), + ); expect(mockCounter.add).not.toHaveBeenCalledWith( 1, - expect.objectContaining({ 'error.type': expect.any(String) }), + expect.objectContaining({ 'error.message': expect.any(String) }), ); }); - it('should not capture error attributes on async fault when captureErrorAttributes is false', async () => { + it('captures structured error type but not message or stack when async captureErrorAttributes is false', async () => { const mockCounter = { add: vi.fn() }; mockMeter.createCounter.mockReturnValue(mockCounter); @@ -181,9 +192,10 @@ describe('ScopedTelemetry', () => { await expect(scopedTelemetry.measureAsync('test', fn, { captureErrorAttributes: false })).rejects.toThrow( 'test error', ); + expect(mockCounter.add).toHaveBeenCalledWith(1, expect.objectContaining({ 'error.type': 'Error' })); expect(mockCounter.add).not.toHaveBeenCalledWith( 1, - expect.objectContaining({ 'error.type': expect.any(String) }), + expect.objectContaining({ 'error.message': expect.any(String) }), ); }); }); @@ -240,7 +252,7 @@ describe('ScopedTelemetry', () => { expect(mockMeter.createCounter).toHaveBeenCalledWith('test.response.type.undefined', expect.any(Object)); }); - it('should not capture error attributes on fault when config is undefined', () => { + it('captures structured error type by default but not message or stack', () => { const mockCounter = { add: vi.fn() }; mockMeter.createCounter.mockReturnValue(mockCounter); @@ -249,13 +261,17 @@ describe('ScopedTelemetry', () => { }); expect(() => scopedTelemetry.trackExecution('test', fn)).toThrow('test error'); + expect(mockCounter.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ 'error.type': 'Error', 'error.code': 'Unknown' }), + ); expect(mockCounter.add).not.toHaveBeenCalledWith( 1, - expect.objectContaining({ 'error.type': expect.any(String) }), + expect.objectContaining({ 'error.message': expect.any(String) }), ); }); - it('should not capture error attributes on fault when captureErrorAttributes is false', () => { + it('captures structured error type but not message or stack when captureErrorAttributes is false', () => { const mockCounter = { add: vi.fn() }; mockMeter.createCounter.mockReturnValue(mockCounter); @@ -266,9 +282,10 @@ describe('ScopedTelemetry', () => { expect(() => scopedTelemetry.trackExecution('test', fn, { captureErrorAttributes: false })).toThrow( 'test error', ); + expect(mockCounter.add).toHaveBeenCalledWith(1, expect.objectContaining({ 'error.type': 'Error' })); expect(mockCounter.add).not.toHaveBeenCalledWith( 1, - expect.objectContaining({ 'error.type': expect.any(String) }), + expect.objectContaining({ 'error.message': expect.any(String) }), ); }); }); @@ -286,20 +303,24 @@ describe('ScopedTelemetry', () => { expect(mockMeter.createCounter).toHaveBeenCalledWith('test.response.type.string', expect.any(Object)); }); - it('should not capture error attributes on async fault when config is undefined', async () => { + it('captures structured error type by default on async fault but not message or stack', async () => { const mockCounter = { add: vi.fn() }; mockMeter.createCounter.mockReturnValue(mockCounter); const fn = vi.fn(() => Promise.reject(new Error('test error'))); await expect(scopedTelemetry.trackExecutionAsync('test', fn)).rejects.toThrow('test error'); + expect(mockCounter.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ 'error.type': 'Error', 'error.code': 'Unknown' }), + ); expect(mockCounter.add).not.toHaveBeenCalledWith( 1, - expect.objectContaining({ 'error.type': expect.any(String) }), + expect.objectContaining({ 'error.message': expect.any(String) }), ); }); - it('should not capture error attributes on async fault when captureErrorAttributes is false', async () => { + it('captures structured error type but not message or stack when async captureErrorAttributes is false', async () => { const mockCounter = { add: vi.fn() }; mockMeter.createCounter.mockReturnValue(mockCounter); @@ -308,9 +329,10 @@ describe('ScopedTelemetry', () => { await expect( scopedTelemetry.trackExecutionAsync('test', fn, { captureErrorAttributes: false }), ).rejects.toThrow('test error'); + expect(mockCounter.add).toHaveBeenCalledWith(1, expect.objectContaining({ 'error.type': 'Error' })); expect(mockCounter.add).not.toHaveBeenCalledWith( 1, - expect.objectContaining({ 'error.type': expect.any(String) }), + expect.objectContaining({ 'error.message': expect.any(String) }), ); }); }); @@ -333,7 +355,7 @@ describe('ScopedTelemetry', () => { mockMeter.createCounter = vi.fn(() => ({ add: mockAdd })); }); - it('should not capture error attributes when config is undefined', () => { + it('captures structured error type by default but not message or stack', () => { const error = new Error('test error'); scopedTelemetry.error('test.error', error); @@ -341,10 +363,12 @@ describe('ScopedTelemetry', () => { expect(mockAdd).toHaveBeenCalledWith(1, { HandlerSource: 'Unknown', 'aws.emf.storage_resolution': 1, + 'error.type': 'Error', + 'error.code': 'Unknown', }); }); - it('should not capture error attributes when captureErrorAttributes is false', () => { + it('captures structured error type but not message or stack when captureErrorAttributes is false', () => { const error = new Error('test error'); scopedTelemetry.error('test.error', error, undefined, { captureErrorAttributes: false }); @@ -352,6 +376,8 @@ describe('ScopedTelemetry', () => { expect(mockAdd).toHaveBeenCalledWith(1, { HandlerSource: 'Unknown', 'aws.emf.storage_resolution': 1, + 'error.type': 'Error', + 'error.code': 'Unknown', }); }); diff --git a/tst/unit/utils/ErrorStackInfo.test.ts b/tst/unit/utils/ErrorStackInfo.test.ts new file mode 100644 index 00000000..b578a105 --- /dev/null +++ b/tst/unit/utils/ErrorStackInfo.test.ts @@ -0,0 +1,621 @@ +import { describe, test, expect } from 'vitest'; +import { errorAttributes, errorType, extractLocationFromStack } from '../../../src/utils/ErrorStackInfo'; + +describe('ErrorStackInfo', () => { + describe('extractLocationFromStack', () => { + test('returns empty object when stack is undefined', () => { + expect(extractLocationFromStack(undefined)).toEqual({}); + }); + + test('returns empty object when stack is empty string', () => { + expect(extractLocationFromStack('')).toEqual({}); + }); + + test('extracts location from stack with parentheses format', () => { + const stack = 'Error: test\n at Object. (/path/to/file.ts:01234:56789)'; + expect(extractLocationFromStack(stack)).toEqual({ + 'error.message': 'Error: test', + 'error.stack': 'at Object. (/path/to/file.ts:01234:56789)', + }); + }); + + test('extracts location from stack without parentheses format', () => { + const stack = 'Error: test\n at /path/to/file.js:01234:56789'; + expect(extractLocationFromStack(stack)).toEqual({ + 'error.message': 'Error: test', + 'error.stack': 'at /path/to/file.js:01234:56789', + }); + }); + + test('extracts filename from Windows path', () => { + const stack = 'Error: test\n at Object. (C:\\path\\to\\file.ts:01234:56789)'; + expect(extractLocationFromStack(stack)).toEqual({ + 'error.message': 'Error: test', + 'error.stack': `at Object. (C:/path/to/file.ts:01234:56789)`, + }); + }); + + test('returns just message when no match found', () => { + const stack = 'Error: test\n at something without location'; + expect(extractLocationFromStack(stack)).toEqual({ + 'error.message': 'Error: test', + 'error.stack': 'at something without location', + }); + }); + + test('extract error from exception', () => { + const stack = String.raw` +Error: Request cancelled for key: SendDocuments + at Delayer.cancel (webpack://aws/cloudformation-languageserver/src/utils/Delayer.ts?f28b:145:28) + at eval (webpack://aws/cloudformation-languageserver/src/utils/Delayer.ts?f28b:36:18) + at new Promise () +`; + expect(extractLocationFromStack(stack)).toEqual({ + 'error.message': 'Error: Request cancelled for key: SendDocuments', + 'error.stack': `at Delayer.cancel (webpack://aws/cloudformation-languageserver/[*]/[*]/Delayer.ts?f28b:145:28) +at eval (webpack://aws/cloudformation-languageserver/[*]/[*]/Delayer.ts?f28b:36:18) +at new Promise ()`, + }); + }); + + test('full stack', () => { + expect( + extractLocationFromStack(String.raw` +Error: ENOENT: no such file or directory, scandir 'some-dir/cloudformation-languageserver/bundle/development/.aws-cfn-storage/lmdb' + at readdirSync (node:fs:1584:26) + at node:electron/js2c/node_init:2:16044 + at LMDBStoreFactory.cleanupOldVersions (webpack://aws/cloudformation-languageserver/src/datastore/LMDB.ts?d928:98:36) + at Timeout.eval (webpack://aws/cloudformation-languageserver/src/datastore/LMDB.ts?d928:58:22) + at listOnTimeout (node:internal/timers:588:17) + at process.processTimers (node:internal/timers:523:7) +`), + ).toEqual({ + 'error.message': + "Error: ENOENT: no such file or directory, scandir 'some-dir/cloudformation-languageserver/bundle/development/.aws-cfn-storage/lmdb'", + 'error.stack': `at readdirSync (node:fs:1584:26) +at node:electron/js2c/node_init:2:16044 +at LMDBStoreFactory.cleanupOldVersions (webpack://aws/cloudformation-languageserver/[*]/datastore/LMDB.ts?d928:98:36) +at Timeout.eval (webpack://aws/cloudformation-languageserver/[*]/datastore/LMDB.ts?d928:58:22) +at listOnTimeout (node:internal/timers:588:17) +at process.processTimers (node:internal/timers:523:7)`, + }); + }); + + test('stack trace from GitHub issue', () => { + expect( + extractLocationFromStack(String.raw` +Error: PeriodicExportingMetricReader: metrics export failed (error Error: socket hang up) + at PeriodicExportingMetricReader._doRun (cloudformation-languageserver/1.0.0/cloudformation-languageserver-1.0.0-darwin-x64-node22/node_modules/@opentelemetry/sdk-metrics/build/src/export/PeriodicExportingMetricReader.js:88:19) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + at async PeriodicExportingMetricReader._runOnce (cloudformation-languageserver/1.0.0/cloudformation-languageserver-1.0.0-darwin-x64-node22/node_modules/@opentelemetry/sdk-metrics/build/src/export/PeriodicExportingMetricReader.js:57:13) +`), + ).toEqual({ + 'error.message': + 'Error: PeriodicExportingMetricReader: metrics export failed (error Error: socket hang up)', + 'error.stack': `at PeriodicExportingMetricReader._doRun (cloudformation-languageserver/1.0.0/cloudformation-languageserver-1.0.0-darwin-x64-node22/node_modules/@opentelemetry/sdk-metrics/build/[*]/export/PeriodicExportingMetricReader.js:88:19) +at process.processTicksAndRejections (node:internal/process/task_queues:105:5) +at async PeriodicExportingMetricReader._runOnce (cloudformation-languageserver/1.0.0/cloudformation-languageserver-1.0.0-darwin-x64-node22/node_modules/@opentelemetry/sdk-metrics/build/[*]/export/PeriodicExportingMetricReader.js:57:13)`, + }); + }); + + test('handles Windows backslash paths', () => { + const stack = String.raw`Error: test + at Object. (C:\testuser\cloudformation-languageserver\\src\file.ts:10:5)`; + + expect(extractLocationFromStack(stack)).toEqual({ + 'error.message': 'Error: test', + 'error.stack': 'at Object. (C:/testuser/cloudformation-languageserver/[*]/file.ts:10:5)', + }); + }); + + test('handles mixed path separators', () => { + const stack = String.raw`Error: test + at func (C:\cloudformation-languageserver\src/file.ts:10:5)`; + + expect(extractLocationFromStack(stack)).toEqual({ + 'error.message': 'Error: test', + 'error.stack': 'at func (C:/cloudformation-languageserver/[*]/file.ts:10:5)', + }); + }); + + test('handles stack with no file location', () => { + const stack = 'Error: test\n at '; + + expect(extractLocationFromStack(stack)).toEqual({ + 'error.message': 'Error: test', + 'error.stack': 'at ', + }); + }); + + test('skips empty lines in stack', () => { + const stack = 'Error: test\n at func1 (file.ts:1:1)\n at \n at func2 (file.ts:2:2)'; + + expect(extractLocationFromStack(stack)).toEqual({ + 'error.message': 'Error: test', + 'error.stack': `at func1 (file.ts:1:1) +at +at func2 (file.ts:2:2)`, + }); + }); + + test('handles node internal modules', () => { + const stack = `Error: test + at Module._compile (node:internal/modules/cjs/loader:1159:14) + at Object.Module._extensions..js (node:internal/modules/cjs/loader:1213:10)`; + + expect(extractLocationFromStack(stack)).toEqual({ + 'error.message': 'Error: test', + 'error.stack': `at Module._compile (node:internal/modules/cjs/loader:1159:14) +at Object.Module._extensions..js (node:internal/modules/cjs/loader:1213:10)`, + }); + }); + }); + + describe('extractLocationFromStack - sensitive data sanitization', () => { + test('sanitizes IAM user ARN with account ID', () => { + const stack = 'AccessDenied: User: arn:aws:iam::123456789012:user/test-user is not authorized'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('AccessDenied: User: arn:aws: is not authorized'); + expect(result['error.message']).not.toContain('123456789012'); + expect(result['error.message']).not.toContain('test-user'); + }); + + test('sanitizes STS assumed role ARN', () => { + const stack = 'arn:aws:sts::123456789012:assumed-role/MyRole/session-name'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('arn:aws:'); + expect(result['error.message']).not.toContain('123456789012'); + expect(result['error.message']).not.toContain('MyRole'); + }); + + test('sanitizes IAM role ARN', () => { + const stack = 'arn:aws:iam::111122223333:role/AdminRole not found'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('arn:aws: not found'); + expect(result['error.message']).not.toContain('111122223333'); + expect(result['error.message']).not.toContain('AdminRole'); + }); + + test('sanitizes standalone 12-digit account ID', () => { + const stack = 'Account 123456789012 not found'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('Account not found'); + }); + + test('does not sanitize S3 ARN without account ID', () => { + const stack = 'arn:aws:s3:::my-bucket'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('arn:aws:s3:::my-bucket'); + }); + + test('sanitizes multiple ARNs in same message', () => { + const stack = + 'User arn:aws:iam::111111111111:user/user-a cannot access arn:aws:iam::222222222222:role/role-b'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('User arn:aws: cannot access arn:aws:'); + }); + + test('sanitizes real AWS AccessDenied error message format', () => { + const stack = `AccessDenied: User: arn:aws:iam::123456789012:user/some-user is not authorized to perform: cloudformation:ListTypes because no identity-based policy allows the cloudformation:ListTypes action + at ProtocolLib.getErrorSchemaOrThrowBaseException (webpack://aws/cloudformation-languageserver/node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/core/dist-es/submodules/protocols/ProtocolLib.js:60:1)`; + const result = extractLocationFromStack(stack); + expect(result['error.message']).not.toMatch(/\d{12}/); + expect(result['error.message']).toContain('AccessDenied'); + expect(result['error.message']).toContain('arn:aws:'); + }); + + test('sanitizes regionalized EC2 ARN', () => { + const stack = 'Error: arn:aws:ec2:us-east-1:123456789012:instance/i-0abcdef1234567890'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('Error: arn:aws:'); + }); + + test('sanitizes aws-cn partition ARN', () => { + const stack = 'Error: arn:aws-cn:lambda:cn-north-1:123456789012:function:my-func'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('Error: arn:aws:'); + }); + + test('sanitizes aws-us-gov partition ARN', () => { + const stack = 'Error: arn:aws-us-gov:rds:us-gov-west-1:123456789012:db:my-db'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('Error: arn:aws:'); + }); + + test('sanitizes aws-iso partition ARN', () => { + const stack = 'Error: arn:aws-iso:ec2:us-iso-east-1:123456789012:instance/i-abc'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('Error: arn:aws:'); + }); + + test('sanitizes global IAM ARN from aws-cn partition', () => { + const stack = 'Error: arn:aws-cn:iam::123456789012:user/test-user'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('Error: arn:aws:'); + }); + + test('sanitizes CloudFront distribution ARN (global service)', () => { + const stack = 'Error: arn:aws:cloudfront::123456789012:distribution/EDFDVBD632BHDS'; + const result = extractLocationFromStack(stack); + expect(result['error.message']).toBe('Error: arn:aws:'); + }); + }); + + describe('errorAttributes', () => { + test('returns attributes for Error with stack and default origin', () => { + const error = new Error('test message'); + error.stack = 'Error: test message\n at func (file.ts:10:5)'; + + const result = errorAttributes(error); + + expect(result).toEqual({ + 'error.origin': 'Unknown', + 'error.message': 'Error: test message', + 'error.stack': 'at func (file.ts:10:5)', + }); + + expect(errorType(error)).toEqual({ + 'error.code': 'Unknown', + 'error.type': 'Error', + }); + }); + + test('returns attributes for custom Error type', () => { + const error = new TypeError('type error'); + error.stack = 'TypeError: type error\n at func (file.ts:1:1)'; + (error as NodeJS.ErrnoException).code = 'SomeCode'; + + const result = errorAttributes(error); + + expect(result).toEqual({ + 'error.origin': 'Unknown', + 'error.message': 'TypeError: type error', + 'error.stack': 'at func (file.ts:1:1)', + }); + + expect(errorType(error)).toEqual({ + 'error.code': 'SomeCode', + 'error.type': 'TypeError', + }); + }); + + test('returns attributes with uncaughtException origin', () => { + const error = new Error('test'); + error.stack = 'Error: test\n at x (x.ts:1:1)'; + + const result = errorAttributes(error, 'uncaughtException'); + + expect(result).toEqual({ + 'error.origin': 'uncaughtException', + 'error.message': 'Error: test', + 'error.stack': 'at x (x.ts:1:1)', + }); + + expect(errorType(error)).toEqual({ + 'error.code': 'Unknown', + 'error.type': 'Error', + }); + }); + + test('returns attributes with unhandledRejection origin', () => { + const error = new Error('test'); + error.stack = 'Error: test\n at x (x.ts:1:1)'; + + const result = errorAttributes(error, 'unhandledRejection'); + + expect(result).toEqual({ + 'error.origin': 'unhandledRejection', + 'error.message': 'Error: test', + 'error.stack': 'at x (x.ts:1:1)', + }); + + expect(errorType(error)).toEqual({ + 'error.code': 'Unknown', + 'error.type': 'Error', + }); + }); + + test('returns attributes for non-Error string value', () => { + const error = 'string error'; + const result = errorAttributes(error); + + expect(result).toEqual({ + 'error.origin': 'Unknown', + }); + + expect(errorType(error)).toEqual({ + 'error.code': 'Unknown', + 'error.type': 'string', + }); + }); + + test('returns attributes for non-Error null value', () => { + const error = null; + const result = errorAttributes(error); + + expect(result).toEqual({ + 'error.origin': 'Unknown', + }); + + expect(errorType(error)).toEqual({ + 'error.code': 'Unknown', + 'error.type': 'object', + }); + }); + + test('returns attributes for non-Error undefined value', () => { + const error = undefined; + const result = errorAttributes(error); + + expect(result).toEqual({ + 'error.origin': 'Unknown', + }); + + expect(errorType(error)).toEqual({ + 'error.code': 'Unknown', + 'error.type': 'undefined', + }); + }); + }); + + describe('errorType / errorAttributes cause walking', () => { + test('errorType surfaces the lmdb-js commitError cause', () => { + const cause = Object.assign(new Error('map full'), { code: 'MDB_MAP_FULL' }); + cause.name = 'MDBError'; + const wrapper = Object.assign(new Error('Commit failed (see commitError for details)'), { + commitError: cause, + }); + + expect(errorType(wrapper)).toEqual({ + 'error.type': 'Error', + 'error.code': 'Unknown', + 'error.cause.type': 'MDBError', + 'error.cause.code': 'MDB_MAP_FULL', + }); + }); + + test('errorType surfaces the ES2022 cause chain', () => { + const cause = Object.assign(new Error('disk full'), { code: 'ENOSPC' }); + const wrapper = new Error('write failed', { cause }); + + expect(errorType(wrapper)).toEqual({ + 'error.type': 'Error', + 'error.code': 'Unknown', + 'error.cause.type': 'Error', + 'error.cause.code': 'ENOSPC', + }); + }); + + test('errorType reports Unknown cause code when the cause has none', () => { + const wrapper = new Error('wrapper', { cause: new Error('inner') }); + + expect(errorType(wrapper)).toEqual({ + 'error.type': 'Error', + 'error.code': 'Unknown', + 'error.cause.type': 'Error', + 'error.cause.code': 'Unknown', + }); + }); + + test('errorAttributes surfaces the sanitized cause message and stack', () => { + const cause = new Error('inner boom'); + cause.stack = 'Error: inner boom\n at inner (file.ts:5:5)'; + const wrapper = Object.assign(new Error('Commit failed'), { commitError: cause }); + wrapper.stack = 'Error: Commit failed\n at outer (file.ts:1:1)'; + + expect(errorAttributes(wrapper)).toEqual({ + 'error.origin': 'Unknown', + 'error.message': 'Error: Commit failed', + 'error.stack': 'at outer (file.ts:1:1)', + 'error.cause.message': 'Error: inner boom', + 'error.cause.stack': 'at inner (file.ts:5:5)', + }); + }); + + test('leaves attributes unchanged when there is no cause', () => { + const error = new Error('standalone'); + error.stack = 'Error: standalone\n at x (x.ts:1:1)'; + + expect(errorAttributes(error)).toEqual({ + 'error.origin': 'Unknown', + 'error.message': 'Error: standalone', + 'error.stack': 'at x (x.ts:1:1)', + }); + expect(errorType(error)).toEqual({ + 'error.type': 'Error', + 'error.code': 'Unknown', + }); + }); + }); + + describe('errorType structured AWS/axios fields and sanitization', () => { + test('captures AWS SDK http status and wire Code', () => { + const awsError = Object.assign(new Error('User is not authorized'), { + Code: 'AccessDenied', + $metadata: { httpStatusCode: 403 }, + }); + awsError.name = 'AccessDeniedException'; + + expect(errorType(awsError)).toEqual({ + 'error.type': 'AccessDeniedException', + 'error.code': 'AccessDenied', + 'error.http.status': 403, + 'error.aws.category': 'permissions', + 'error.aws.http.status': '403', + }); + }); + + test('captures axios response status and code', () => { + const axiosError = Object.assign(new Error('Request failed with status code 503'), { + code: 'ERR_BAD_RESPONSE', + response: { status: 503 }, + }); + axiosError.name = 'AxiosError'; + + expect(errorType(axiosError)).toEqual({ + 'error.type': 'AxiosError', + 'error.code': 'ERR_BAD_RESPONSE', + 'error.http.status': 503, + }); + }); + + test('captures http status from the cause', () => { + const cause = Object.assign(new Error('throttled'), { $metadata: { httpStatusCode: 429 } }); + cause.name = 'ThrottlingException'; + const wrapper = new Error('wrapper', { cause }); + + expect(errorType(wrapper)).toEqual({ + 'error.type': 'Error', + 'error.code': 'Unknown', + 'error.cause.type': 'ThrottlingException', + 'error.cause.code': 'Unknown', + 'error.cause.http.status': '429', + }); + }); + + test('sanitizes account IDs and ARNs in code and type', () => { + const error = Object.assign(new Error('boom'), { code: 'arn:aws:iam::123456789012:role/secret' }); + error.name = 'Err-123456789012'; + + const result = errorType(error); + + expect(result['error.code']).toBe('arn:aws:'); + expect(result['error.type']).toBe('Err-'); + }); + }); + + describe('errorType AWS classification attributes', () => { + test('omits AWS attributes for non-AWS errors (category unknown)', () => { + const result = errorType(new Error('plain bug')); + + expect(result['error.aws.category']).toBeUndefined(); + expect(result['error.aws.http.status']).toBeUndefined(); + }); + + test('omits AWS attributes for primitive non-Error inputs', () => { + expect(errorType('string error')['error.aws.category']).toBeUndefined(); + expect(errorType(null)['error.aws.category']).toBeUndefined(); + expect(errorType(undefined)['error.aws.category']).toBeUndefined(); + }); + + test('classifies AWS credentials errors (ExpiredTokenException) without http status', () => { + const error = Object.assign(new Error('expired'), { name: 'ExpiredTokenException' }); + + expect(errorType(error)).toMatchObject({ + 'error.type': 'ExpiredTokenException', + 'error.aws.category': 'credentials', + }); + expect(errorType(error)['error.aws.http.status']).toBeUndefined(); + }); + + test('classifies AWS credentials errors via 401 http status', () => { + const error = Object.assign(new Error('unauthorized'), { + $metadata: { httpStatusCode: 401 }, + }); + + expect(errorType(error)).toMatchObject({ + 'error.aws.category': 'credentials', + 'error.aws.http.status': '401', + }); + }); + + test('classifies permissions via AccessDenied name without http status', () => { + const error = Object.assign(new Error('forbidden'), { name: 'AccessDenied' }); + + expect(errorType(error)).toMatchObject({ + 'error.aws.category': 'permissions', + }); + expect(errorType(error)['error.aws.http.status']).toBeUndefined(); + }); + + test('classifies permissions via 403 http status alone', () => { + const error = Object.assign(new Error('forbidden'), { + name: 'SomeOtherException', + $metadata: { httpStatusCode: 403 }, + }); + + expect(errorType(error)).toMatchObject({ + 'error.aws.category': 'permissions', + 'error.aws.http.status': '403', + }); + }); + + test('classifies throttling via ThrottlingException name', () => { + const error = Object.assign(new Error('rate exceeded'), { name: 'ThrottlingException' }); + + expect(errorType(error)).toMatchObject({ + 'error.aws.category': 'throttling', + }); + }); + + test('classifies throttling via 429 http status alone', () => { + const error = Object.assign(new Error('rate exceeded'), { + name: 'SomeException', + $metadata: { httpStatusCode: 429 }, + }); + + expect(errorType(error)).toMatchObject({ + 'error.aws.category': 'throttling', + 'error.aws.http.status': '429', + }); + }); + + test('classifies generic 4xx (non-401/403/429) AWS responses as service', () => { + const error = Object.assign(new Error('not found'), { + name: 'ResourceNotFoundException', + $metadata: { httpStatusCode: 404 }, + }); + + expect(errorType(error)).toMatchObject({ + 'error.aws.category': 'service', + 'error.aws.http.status': '404', + }); + }); + + test('classifies generic 5xx AWS responses as service', () => { + const error = Object.assign(new Error('boom'), { + name: 'InternalFailure', + $metadata: { httpStatusCode: 500 }, + }); + + expect(errorType(error)).toMatchObject({ + 'error.aws.category': 'service', + 'error.aws.http.status': '500', + }); + }); + + test('credentials category beats permissions when both 401 (credentials) and AccessDeniedException name conflict', () => { + // Hard-coded order: credential checks fire first, so 401 wins over AccessDeniedException. + const error = Object.assign(new Error('unauth'), { + name: 'AccessDeniedException', + $metadata: { httpStatusCode: 401 }, + }); + + expect(errorType(error)['error.aws.category']).toBe('credentials'); + }); + + test('does not classify the wrapper from its cause', () => { + // classifyAwsError walks only the immediate error, not its cause chain. + const cause = Object.assign(new Error('inner'), { + name: 'AccessDeniedException', + $metadata: { httpStatusCode: 403 }, + }); + const wrapper = new Error('wrapper', { cause }); + + expect(errorType(wrapper)['error.aws.category']).toBeUndefined(); + // Cause-derived attributes still get surfaced separately. + expect(errorType(wrapper)['error.cause.type']).toBe('AccessDeniedException'); + expect(errorType(wrapper)['error.cause.http.status']).toBe('403'); + }); + + test('sanitizes the http status string field', () => { + const error = Object.assign(new Error('boom'), { + name: 'Some', + $metadata: { httpStatusCode: 500 }, + }); + + // Sanity check: http.status is stringified via sanitizeMessage. + expect(typeof errorType(error)['error.aws.http.status']).toBe('string'); + }); + }); +}); diff --git a/tst/unit/utils/Errors.test.ts b/tst/unit/utils/Errors.test.ts index 2bebb1d1..2a322819 100644 --- a/tst/unit/utils/Errors.test.ts +++ b/tst/unit/utils/Errors.test.ts @@ -1,404 +1,395 @@ -import { describe, test, expect } from 'vitest'; -import { errorAttributes, errorType, extractLocationFromStack, isClientNetworkError } from '../../../src/utils/Errors'; - -describe('isClientNetworkError', () => { - test('returns true for SSL certificate errors', () => { - expect(isClientNetworkError(new Error('unable to get local issuer certificate'))).toBe(true); - expect(isClientNetworkError(new Error('self signed certificate in certificate chain'))).toBe(true); - expect(isClientNetworkError(new Error('unable to verify the first certificate'))).toBe(true); - expect(isClientNetworkError(new Error('certificate has expired'))).toBe(true); - expect(isClientNetworkError(new Error('Hostname does not match certificate altnames'))).toBe(true); - expect(isClientNetworkError(new Error('WRONG_VERSION_NUMBER'))).toBe(true); - }); +import { ErrorCodes, ResponseError } from 'vscode-languageserver'; +import { describe, expect, test } from 'vitest'; +import { + DoesNotExist, + extractErrorCode, + extractErrorMessage, + extractHttpStatus, + extractRootCause, + extractStatusReason, + handleLspError, + isClientNetworkError, +} from '../../../src/utils/Errors'; + +describe('Errors', () => { + describe('isClientNetworkError', () => { + test('returns true for SSL certificate errors', () => { + expect(isClientNetworkError(new Error('unable to get local issuer certificate'))).toBe(true); + expect(isClientNetworkError(new Error('self signed certificate in certificate chain'))).toBe(true); + expect(isClientNetworkError(new Error('unable to verify the first certificate'))).toBe(true); + expect(isClientNetworkError(new Error('certificate has expired'))).toBe(true); + expect(isClientNetworkError(new Error('Hostname does not match certificate altnames'))).toBe(true); + expect(isClientNetworkError(new Error('WRONG_VERSION_NUMBER'))).toBe(true); + }); - test('returns true for network connectivity errors', () => { - expect(isClientNetworkError(new Error('read ECONNRESET'))).toBe(true); - expect(isClientNetworkError(new Error('connect ETIMEDOUT'))).toBe(true); - expect(isClientNetworkError(new Error('connect ECONNREFUSED'))).toBe(true); - expect(isClientNetworkError(new Error('getaddrinfo ENOTFOUND'))).toBe(true); - expect(isClientNetworkError(new Error('getaddrinfo EAI_AGAIN'))).toBe(true); - expect(isClientNetworkError(new Error('read ECONNABORTED'))).toBe(true); - expect(isClientNetworkError(new Error('connect EBADF'))).toBe(true); - expect(isClientNetworkError(new Error('socket hang up'))).toBe(true); - expect(isClientNetworkError(new Error('network socket disconnected'))).toBe(true); - expect(isClientNetworkError(new Error('TOO_MANY_REDIRECTS'))).toBe(true); - expect(isClientNetworkError(new Error('Parse Error: Expected HTTP/'))).toBe(true); - }); + test('returns true for network connectivity errors', () => { + expect(isClientNetworkError(new Error('read ECONNRESET'))).toBe(true); + expect(isClientNetworkError(new Error('connect ETIMEDOUT'))).toBe(true); + expect(isClientNetworkError(new Error('connect ECONNREFUSED'))).toBe(true); + expect(isClientNetworkError(new Error('getaddrinfo ENOTFOUND'))).toBe(true); + expect(isClientNetworkError(new Error('getaddrinfo EAI_AGAIN'))).toBe(true); + expect(isClientNetworkError(new Error('read ECONNABORTED'))).toBe(true); + expect(isClientNetworkError(new Error('socket hang up'))).toBe(true); + expect(isClientNetworkError(new Error('network socket disconnected'))).toBe(true); + expect(isClientNetworkError(new Error('TOO_MANY_REDIRECTS'))).toBe(true); + expect(isClientNetworkError(new Error('Parse Error: Expected HTTP/'))).toBe(true); + }); - test('returns true for proxy authentication errors', () => { - expect(isClientNetworkError(new Error('Request failed with status code 407'))).toBe(true); - }); + test('returns true for proxy authentication errors', () => { + expect(isClientNetworkError(new Error('Request failed with status code 407'))).toBe(true); + }); - test('returns false for server-side errors', () => { - expect(isClientNetworkError(new Error('Request failed with status code 500'))).toBe(false); - expect(isClientNetworkError(new Error('Request failed with status code 503'))).toBe(false); - expect(isClientNetworkError(new Error('Internal server error'))).toBe(false); - }); + test('returns false for server-side errors', () => { + expect(isClientNetworkError(new Error('Request failed with status code 500'))).toBe(false); + expect(isClientNetworkError(new Error('Request failed with status code 503'))).toBe(false); + expect(isClientNetworkError(new Error('Internal server error'))).toBe(false); + }); - test('returns false for non-network errors', () => { - expect(isClientNetworkError(new Error('Unexpected token'))).toBe(false); - expect(isClientNetworkError(new Error('Cannot read property of undefined'))).toBe(false); - }); + test('returns false for non-network errors', () => { + expect(isClientNetworkError(new Error('Unexpected token'))).toBe(false); + expect(isClientNetworkError(new Error('Cannot read property of undefined'))).toBe(false); + }); - test('handles non-Error values', () => { - expect(isClientNetworkError('ECONNRESET')).toBe(true); - expect(isClientNetworkError('random string')).toBe(false); - expect(isClientNetworkError(null)).toBe(false); - expect(isClientNetworkError(undefined)).toBe(false); - }); -}); + test('inspects error code in addition to message', () => { + const redirectError = Object.assign(new Error('Maximum number of redirects exceeded'), { + code: 'ERR_FR_TOO_MANY_REDIRECTS', + }); + expect(isClientNetworkError(redirectError)).toBe(true); -describe('extractLocationFromStack', () => { - test('returns empty object when stack is undefined', () => { - expect(extractLocationFromStack(undefined)).toEqual({}); - }); + const resetByCode = Object.assign(new Error('something went wrong'), { code: 'ECONNRESET' }); + expect(isClientNetworkError(resetByCode)).toBe(true); + }); - test('returns empty object when stack is empty string', () => { - expect(extractLocationFromStack('')).toEqual({}); - }); + test('does not misclassify a server error that lacks a client-side code or name', () => { + const serverError = Object.assign(new Error('Request failed with status code 503'), { + code: 'ERR_BAD_RESPONSE', + name: 'AxiosError', + }); + expect(isClientNetworkError(serverError)).toBe(false); + }); - test('extracts location from stack with parentheses format', () => { - const stack = 'Error: test\n at Object. (/path/to/file.ts:01234:56789)'; - expect(extractLocationFromStack(stack)).toEqual({ - 'error.message': 'Error: test', - 'error.stack': 'at Object. (/path/to/file.ts:01234:56789)', + test('handles non-Error values', () => { + expect(isClientNetworkError('ECONNRESET')).toBe(true); + expect(isClientNetworkError('random string')).toBe(false); + expect(isClientNetworkError(null)).toBe(false); + expect(isClientNetworkError(undefined)).toBe(false); }); - }); - test('extracts location from stack without parentheses format', () => { - const stack = 'Error: test\n at /path/to/file.js:01234:56789'; - expect(extractLocationFromStack(stack)).toEqual({ - 'error.message': 'Error: test', - 'error.stack': 'at /path/to/file.js:01234:56789', + test('ignores non-string code and name fields, still inspects the message', () => { + // Branch: object error where `code` and `name` are present but not strings — + // the function should ignore them and rely on the message. + const matchingMessage = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1'), { code: 42, name: 99 }); + expect(isClientNetworkError(matchingMessage)).toBe(true); + + const nonMatchingMessage = Object.assign(new Error('something completely unrelated'), { code: 1, name: 2 }); + expect(isClientNetworkError(nonMatchingMessage)).toBe(false); }); }); - test('extracts filename from Windows path', () => { - const stack = 'Error: test\n at Object. (C:\\path\\to\\file.ts:01234:56789)'; - expect(extractLocationFromStack(stack)).toEqual({ - 'error.message': 'Error: test', - 'error.stack': `at Object. (C:/path/to/file.ts:01234:56789)`, + describe('extractStatusReason', () => { + test('returns the StatusReason from a JSON-encoded error message', () => { + const error = new Error(JSON.stringify({ reason: { StatusReason: 'Stack rolled back' } })); + expect(extractStatusReason(error)).toBe('Stack rolled back'); }); - }); - test('returns just message when no match found', () => { - const stack = 'Error: test\n at something without location'; - expect(extractLocationFromStack(stack)).toEqual({ - 'error.message': 'Error: test', - 'error.stack': 'at something without location', + test('accepts a non-Error string-coerced value as input', () => { + const message = JSON.stringify({ reason: { StatusReason: 'invalid template' } }); + expect(extractStatusReason(message)).toBe('invalid template'); }); - }); - test('extract error from exception', () => { - const stack = String.raw` -Error: Request cancelled for key: SendDocuments - at Delayer.cancel (webpack://aws/cloudformation-languageserver/src/utils/Delayer.ts?f28b:145:28) - at eval (webpack://aws/cloudformation-languageserver/src/utils/Delayer.ts?f28b:36:18) - at new Promise () -`; - expect(extractLocationFromStack(stack)).toEqual({ - 'error.message': 'Error: Request cancelled for key: SendDocuments', - 'error.stack': `at Delayer.cancel (webpack://aws/cloudformation-languageserver/[*]/[*]/Delayer.ts?f28b:145:28) -at eval (webpack://aws/cloudformation-languageserver/[*]/[*]/Delayer.ts?f28b:36:18) -at new Promise ()`, + test('returns undefined when JSON has no reason field', () => { + const error = new Error(JSON.stringify({ other: 'value' })); + expect(extractStatusReason(error)).toBeUndefined(); }); - }); - test('full stack', () => { - expect( - extractLocationFromStack(String.raw` -Error: ENOENT: no such file or directory, scandir 'some-dir/cloudformation-languageserver/bundle/development/.aws-cfn-storage/lmdb' - at readdirSync (node:fs:1584:26) - at node:electron/js2c/node_init:2:16044 - at LMDBStoreFactory.cleanupOldVersions (webpack://aws/cloudformation-languageserver/src/datastore/LMDB.ts?d928:98:36) - at Timeout.eval (webpack://aws/cloudformation-languageserver/src/datastore/LMDB.ts?d928:58:22) - at listOnTimeout (node:internal/timers:588:17) - at process.processTimers (node:internal/timers:523:7) -`), - ).toEqual({ - 'error.message': - "Error: ENOENT: no such file or directory, scandir 'some-dir/cloudformation-languageserver/bundle/development/.aws-cfn-storage/lmdb'", - 'error.stack': `at readdirSync (node:fs:1584:26) -at node:electron/js2c/node_init:2:16044 -at LMDBStoreFactory.cleanupOldVersions (webpack://aws/cloudformation-languageserver/[*]/datastore/LMDB.ts?d928:98:36) -at Timeout.eval (webpack://aws/cloudformation-languageserver/[*]/datastore/LMDB.ts?d928:58:22) -at listOnTimeout (node:internal/timers:588:17) -at process.processTimers (node:internal/timers:523:7)`, + test('returns undefined when JSON has reason but no StatusReason', () => { + const error = new Error(JSON.stringify({ reason: { something: 'else' } })); + expect(extractStatusReason(error)).toBeUndefined(); }); - }); - test('stack trace from GitHub issue', () => { - expect( - extractLocationFromStack(String.raw` -Error: PeriodicExportingMetricReader: metrics export failed (error Error: socket hang up) - at PeriodicExportingMetricReader._doRun (cloudformation-languageserver/1.0.0/cloudformation-languageserver-1.0.0-darwin-x64-node22/node_modules/@opentelemetry/sdk-metrics/build/src/export/PeriodicExportingMetricReader.js:88:19) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async PeriodicExportingMetricReader._runOnce (cloudformation-languageserver/1.0.0/cloudformation-languageserver-1.0.0-darwin-x64-node22/node_modules/@opentelemetry/sdk-metrics/build/src/export/PeriodicExportingMetricReader.js:57:13) -`), - ).toEqual({ - 'error.message': - 'Error: PeriodicExportingMetricReader: metrics export failed (error Error: socket hang up)', - 'error.stack': `at PeriodicExportingMetricReader._doRun (cloudformation-languageserver/1.0.0/cloudformation-languageserver-1.0.0-darwin-x64-node22/node_modules/@opentelemetry/sdk-metrics/build/[*]/export/PeriodicExportingMetricReader.js:88:19) -at process.processTicksAndRejections (node:internal/process/task_queues:105:5) -at async PeriodicExportingMetricReader._runOnce (cloudformation-languageserver/1.0.0/cloudformation-languageserver-1.0.0-darwin-x64-node22/node_modules/@opentelemetry/sdk-metrics/build/[*]/export/PeriodicExportingMetricReader.js:57:13)`, + test('returns undefined when StatusReason is the empty string (falsy)', () => { + const error = new Error(JSON.stringify({ reason: { StatusReason: '' } })); + expect(extractStatusReason(error)).toBeUndefined(); }); - }); - test('handles Windows backslash paths', () => { - const stack = String.raw`Error: test - at Object. (C:\testuser\cloudformation-languageserver\\src\file.ts:10:5)`; + test('returns undefined for non-JSON error messages', () => { + const error = new Error('plain text message'); + expect(extractStatusReason(error)).toBeUndefined(); + }); - expect(extractLocationFromStack(stack)).toEqual({ - 'error.message': 'Error: test', - 'error.stack': 'at Object. (C:/testuser/cloudformation-languageserver/[*]/file.ts:10:5)', + test('returns undefined for non-Error / non-JSON inputs', () => { + expect(extractStatusReason('not json')).toBeUndefined(); + expect(extractStatusReason(null)).toBeUndefined(); + expect(extractStatusReason(undefined)).toBeUndefined(); + expect(extractStatusReason(42)).toBeUndefined(); }); }); - test('handles mixed path separators', () => { - const stack = String.raw`Error: test - at func (C:\cloudformation-languageserver\src/file.ts:10:5)`; + describe('extractErrorMessage', () => { + test('returns the bare message for a base Error', () => { + expect(extractErrorMessage(new Error('boom'))).toBe('boom'); + }); - expect(extractLocationFromStack(stack)).toEqual({ - 'error.message': 'Error: test', - 'error.stack': 'at func (C:/cloudformation-languageserver/[*]/file.ts:10:5)', + test('prefixes the name for a non-base Error subclass', () => { + expect(extractErrorMessage(new TypeError('not a function'))).toBe('TypeError: not a function'); }); - }); - test('handles stack with no file location', () => { - const stack = 'Error: test\n at '; + test('prefixes a custom error name', () => { + const error = new Error('oops'); + error.name = 'CustomError'; + expect(extractErrorMessage(error)).toBe('CustomError: oops'); + }); - expect(extractLocationFromStack(stack)).toEqual({ - 'error.message': 'Error: test', - 'error.stack': 'at ', + test('stringifies a plain string non-Error', () => { + expect(extractErrorMessage('plain string')).toBe('plain string'); }); - }); - test('skips empty lines in stack', () => { - const stack = 'Error: test\n at func1 (file.ts:1:1)\n at \n at func2 (file.ts:2:2)'; + test('stringifies a number', () => { + expect(extractErrorMessage(42)).toBe('42'); + }); - expect(extractLocationFromStack(stack)).toEqual({ - 'error.message': 'Error: test', - 'error.stack': `at func1 (file.ts:1:1) -at -at func2 (file.ts:2:2)`, + test('stringifies null', () => { + expect(extractErrorMessage(null)).toBe('null'); }); - }); - test('handles node internal modules', () => { - const stack = `Error: test - at Module._compile (node:internal/modules/cjs/loader:1159:14) - at Object.Module._extensions..js (node:internal/modules/cjs/loader:1213:10)`; + test('stringifies undefined', () => { + expect(extractErrorMessage(undefined)).toBe('undefined'); + }); - expect(extractLocationFromStack(stack)).toEqual({ - 'error.message': 'Error: test', - 'error.stack': `at Module._compile (node:internal/modules/cjs/loader:1159:14) -at Object.Module._extensions..js (node:internal/modules/cjs/loader:1213:10)`, + test('formats a plain object via toString', () => { + const result = extractErrorMessage({ code: 'NotFound' }); + // toString() pretty-prints objects — assert on the salient content rather than exact whitespace. + expect(result).toContain('code'); + expect(result).toContain('NotFound'); }); }); -}); -describe('extractLocationFromStack - sensitive data sanitization', () => { - test('sanitizes IAM user ARN with account ID', () => { - const stack = 'AccessDenied: User: arn:aws:iam::123456789012:user/test-user is not authorized'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('AccessDenied: User: arn:aws: is not authorized'); - expect(result['error.message']).not.toContain('123456789012'); - expect(result['error.message']).not.toContain('test-user'); - }); + describe('handleLspError', () => { + test('rethrows an existing ResponseError unchanged', () => { + const original = new ResponseError(ErrorCodes.InvalidRequest, 'bad request'); + expect(() => handleLspError(original, 'context')).toThrow(original); + }); - test('sanitizes STS assumed role ARN', () => { - const stack = 'arn:aws:sts::123456789012:assumed-role/MyRole/session-name'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('arn:aws:'); - expect(result['error.message']).not.toContain('123456789012'); - expect(result['error.message']).not.toContain('MyRole'); - }); + test('maps a TypeError to InvalidParams', () => { + const typeError = new TypeError('expected a string'); + + try { + handleLspError(typeError, 'someOperation'); + expect.fail('handleLspError should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ResponseError); + const responseError = err as ResponseError; + expect(responseError.code).toBe(ErrorCodes.InvalidParams); + expect(responseError.message).toBe('expected a string'); + } + }); - test('sanitizes IAM role ARN', () => { - const stack = 'arn:aws:iam::111122223333:role/AdminRole not found'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('arn:aws: not found'); - expect(result['error.message']).not.toContain('111122223333'); - expect(result['error.message']).not.toContain('AdminRole'); - }); + test('wraps a generic Error as InternalError with the supplied context message', () => { + const error = new Error('disk full'); + + try { + handleLspError(error, 'Failed to write template'); + expect.fail('handleLspError should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ResponseError); + const responseError = err as ResponseError; + expect(responseError.code).toBe(ErrorCodes.InternalError); + expect(responseError.message).toBe('Failed to write template: disk full'); + } + }); - test('sanitizes standalone 12-digit account ID', () => { - const stack = 'Account 123456789012 not found'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('Account not found'); + test('wraps a non-Error value as InternalError using extractErrorMessage', () => { + try { + handleLspError('string error', 'something failed'); + expect.fail('handleLspError should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ResponseError); + const responseError = err as ResponseError; + expect(responseError.code).toBe(ErrorCodes.InternalError); + expect(responseError.message).toBe('something failed: string error'); + } + }); }); - test('does not sanitize S3 ARN without account ID', () => { - const stack = 'arn:aws:s3:::my-bucket'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('arn:aws:s3:::my-bucket'); - }); + describe('extractRootCause', () => { + test('returns the lmdb-style commitError when present', () => { + const cause = new Error('inner'); + const wrapper = Object.assign(new Error('outer'), { commitError: cause }); + expect(extractRootCause(wrapper)).toBe(cause); + }); - test('sanitizes multiple ARNs in same message', () => { - const stack = 'User arn:aws:iam::111111111111:user/user-a cannot access arn:aws:iam::222222222222:role/role-b'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('User arn:aws: cannot access arn:aws:'); - }); + test('returns the ES2022 cause when present', () => { + const cause = new Error('inner'); + const wrapper = new Error('outer', { cause }); + expect(extractRootCause(wrapper)).toBe(cause); + }); - test('sanitizes real AWS AccessDenied error message format', () => { - const stack = `AccessDenied: User: arn:aws:iam::123456789012:user/some-user is not authorized to perform: cloudformation:ListTypes because no identity-based policy allows the cloudformation:ListTypes action - at ProtocolLib.getErrorSchemaOrThrowBaseException (webpack://aws/cloudformation-languageserver/node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/core/dist-es/submodules/protocols/ProtocolLib.js:60:1)`; - const result = extractLocationFromStack(stack); - expect(result['error.message']).not.toMatch(/\d{12}/); - expect(result['error.message']).toContain('AccessDenied'); - expect(result['error.message']).toContain('arn:aws:'); - }); + test('prefers commitError over cause when both are set', () => { + const commitCause = new Error('commit'); + const otherCause = new Error('other'); + const wrapper = Object.assign(new Error('outer', { cause: otherCause }), { commitError: commitCause }); + expect(extractRootCause(wrapper)).toBe(commitCause); + }); - test('sanitizes regionalized EC2 ARN', () => { - const stack = 'Error: arn:aws:ec2:us-east-1:123456789012:instance/i-0abcdef1234567890'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('Error: arn:aws:'); - }); + test('returns undefined when commitError is not an Error instance', () => { + const wrapper = Object.assign(new Error('outer'), { commitError: 'not an error' }); + expect(extractRootCause(wrapper)).toBeUndefined(); + }); - test('sanitizes aws-cn partition ARN', () => { - const stack = 'Error: arn:aws-cn:lambda:cn-north-1:123456789012:function:my-func'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('Error: arn:aws:'); - }); + test('returns undefined when cause is not an Error instance', () => { + const wrapper = Object.assign(new Error('outer'), { cause: 'not an error' }); + expect(extractRootCause(wrapper)).toBeUndefined(); + }); - test('sanitizes aws-us-gov partition ARN', () => { - const stack = 'Error: arn:aws-us-gov:rds:us-gov-west-1:123456789012:db:my-db'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('Error: arn:aws:'); - }); + test('returns undefined for null', () => { + expect(extractRootCause(null)).toBeUndefined(); + }); - test('sanitizes aws-iso partition ARN', () => { - const stack = 'Error: arn:aws-iso:ec2:us-iso-east-1:123456789012:instance/i-abc'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('Error: arn:aws:'); - }); + test('returns undefined for non-object values', () => { + expect(extractRootCause('string')).toBeUndefined(); + expect(extractRootCause(42)).toBeUndefined(); + expect(extractRootCause(undefined)).toBeUndefined(); + }); - test('sanitizes global IAM ARN from aws-cn partition', () => { - const stack = 'Error: arn:aws-cn:iam::123456789012:user/test-user'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('Error: arn:aws:'); + test('returns undefined for objects with no cause fields', () => { + expect(extractRootCause({})).toBeUndefined(); + expect(extractRootCause(new Error('lonely'))).toBeUndefined(); + }); }); - test('sanitizes CloudFront distribution ARN (global service)', () => { - const stack = 'Error: arn:aws:cloudfront::123456789012:distribution/EDFDVBD632BHDS'; - const result = extractLocationFromStack(stack); - expect(result['error.message']).toBe('Error: arn:aws:'); - }); -}); + describe('extractErrorCode', () => { + test('returns the lowercase code field', () => { + expect(extractErrorCode(Object.assign(new Error('x'), { code: 'ECONNRESET' }))).toBe('ECONNRESET'); + }); -describe('errorAttributes', () => { - test('returns attributes for Error with stack and default origin', () => { - const error = new Error('test message'); - error.stack = 'Error: test message\n at func (file.ts:10:5)'; + test('returns the AWS SDK Code field when code is missing', () => { + expect(extractErrorCode(Object.assign(new Error('x'), { Code: 'AccessDenied' }))).toBe('AccessDenied'); + }); - const result = errorAttributes(error); + test('returns the upper-case CODE field as a last string fallback', () => { + expect(extractErrorCode(Object.assign(new Error('x'), { CODE: 'WEIRD_FORMAT' }))).toBe('WEIRD_FORMAT'); + }); - expect(result).toEqual({ - 'error.origin': 'Unknown', - 'error.message': 'Error: test message', - 'error.stack': 'at func (file.ts:10:5)', + test('falls back to errno stringified', () => { + expect(extractErrorCode(Object.assign(new Error('x'), { errno: -2 }))).toBe('-2'); }); - expect(errorType(error)).toEqual({ - 'error.code': 'Unknown', - 'error.type': 'Error', + test('prefers code over Code, CODE, and errno', () => { + const error = Object.assign(new Error('x'), { + code: 'first', + Code: 'second', + CODE: 'third', + errno: 4, + }); + expect(extractErrorCode(error)).toBe('first'); }); - }); - test('returns attributes for custom Error type', () => { - const error = new TypeError('type error'); - error.stack = 'TypeError: type error\n at func (file.ts:1:1)'; - (error as NodeJS.ErrnoException).code = 'SomeCode'; + test('returns undefined when no code-like field is present', () => { + expect(extractErrorCode(new Error('x'))).toBeUndefined(); + }); - const result = errorAttributes(error); + test('returns undefined for null', () => { + expect(extractErrorCode(null)).toBeUndefined(); + }); - expect(result).toEqual({ - 'error.origin': 'Unknown', - 'error.message': 'TypeError: type error', - 'error.stack': 'at func (file.ts:1:1)', + test('returns undefined for non-object values', () => { + expect(extractErrorCode('string')).toBeUndefined(); + expect(extractErrorCode(42)).toBeUndefined(); + expect(extractErrorCode(undefined)).toBeUndefined(); }); - expect(errorType(error)).toEqual({ - 'error.code': 'SomeCode', - 'error.type': 'TypeError', + test('ignores non-string code values', () => { + // Only strings (and numeric errno) are surfaced. + expect(extractErrorCode(Object.assign(new Error('x'), { code: 123 }))).toBeUndefined(); }); }); - test('returns attributes with uncaughtException origin', () => { - const error = new Error('test'); - error.stack = 'Error: test\n at x (x.ts:1:1)'; - - const result = errorAttributes(error, 'uncaughtException'); - - expect(result).toEqual({ - 'error.origin': 'uncaughtException', - 'error.message': 'Error: test', - 'error.stack': 'at x (x.ts:1:1)', + describe('extractHttpStatus', () => { + test('returns AWS SDK $metadata.httpStatusCode when present', () => { + const error = Object.assign(new Error('x'), { $metadata: { httpStatusCode: 403 } }); + expect(extractHttpStatus(error)).toBe(403); }); - expect(errorType(error)).toEqual({ - 'error.code': 'Unknown', - 'error.type': 'Error', + test('returns axios-style response.status when $metadata is missing', () => { + const error = Object.assign(new Error('x'), { response: { status: 503 } }); + expect(extractHttpStatus(error)).toBe(503); }); - }); - test('returns attributes with unhandledRejection origin', () => { - const error = new Error('test'); - error.stack = 'Error: test\n at x (x.ts:1:1)'; + test('returns plain status when neither $metadata nor response is present', () => { + const error = Object.assign(new Error('x'), { status: 404 }); + expect(extractHttpStatus(error)).toBe(404); + }); - const result = errorAttributes(error, 'unhandledRejection'); + test('prefers $metadata over response and status', () => { + const error = Object.assign(new Error('x'), { + $metadata: { httpStatusCode: 401 }, + response: { status: 500 }, + status: 200, + }); + expect(extractHttpStatus(error)).toBe(401); + }); - expect(result).toEqual({ - 'error.origin': 'unhandledRejection', - 'error.message': 'Error: test', - 'error.stack': 'at x (x.ts:1:1)', + test('prefers response over status when $metadata is missing', () => { + const error = Object.assign(new Error('x'), { response: { status: 502 }, status: 200 }); + expect(extractHttpStatus(error)).toBe(502); }); - expect(errorType(error)).toEqual({ - 'error.code': 'Unknown', - 'error.type': 'Error', + test('returns undefined when no http status is present', () => { + expect(extractHttpStatus(new Error('x'))).toBeUndefined(); }); - }); - test('returns attributes for non-Error string value', () => { - const error = 'string error'; - const result = errorAttributes(error); + test('returns undefined when status fields are not numbers', () => { + const error = Object.assign(new Error('x'), { + $metadata: { httpStatusCode: '500' }, + response: { status: '500' }, + status: '500', + }); + expect(extractHttpStatus(error)).toBeUndefined(); + }); - expect(result).toEqual({ - 'error.origin': 'Unknown', + test('returns undefined for null', () => { + expect(extractHttpStatus(null)).toBeUndefined(); }); - expect(errorType(error)).toEqual({ - 'error.code': 'Unknown', - 'error.type': 'string', + test('returns undefined for primitive values', () => { + expect(extractHttpStatus('string')).toBeUndefined(); + expect(extractHttpStatus(42)).toBeUndefined(); + expect(extractHttpStatus(undefined)).toBeUndefined(); }); }); - test('returns attributes for non-Error null value', () => { - const error = null; - const result = errorAttributes(error); + describe('DoesNotExist', () => { + test('formats the message with the supplied resource name', () => { + const error = new DoesNotExist('Stack arn'); + expect(error.message).toBe('Stack arn does not exist'); + }); - expect(result).toEqual({ - 'error.origin': 'Unknown', + test('sets the error name to DoesNotExist', () => { + expect(new DoesNotExist('thing').name).toBe('DoesNotExist'); }); - expect(errorType(error)).toEqual({ - 'error.code': 'Unknown', - 'error.type': 'object', + test('survives instanceof Error', () => { + expect(new DoesNotExist('thing')).toBeInstanceOf(Error); }); - }); - test('returns attributes for non-Error undefined value', () => { - const error = undefined; - const result = errorAttributes(error); + test('survives instanceof DoesNotExist (prototype chain preserved)', () => { + expect(new DoesNotExist('thing')).toBeInstanceOf(DoesNotExist); + }); - expect(result).toEqual({ - 'error.origin': 'Unknown', + test('preserves the cause from ErrorOptions', () => { + const cause = new Error('underlying io error'); + const error = new DoesNotExist('Stack arn', { cause }); + expect(error.cause).toBe(cause); }); - expect(errorType(error)).toEqual({ - 'error.code': 'Unknown', - 'error.type': 'undefined', + test('is throwable and catchable as an Error', () => { + try { + throw new DoesNotExist('Resource X'); + } catch (err) { + expect(err).toBeInstanceOf(DoesNotExist); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe('Resource X does not exist'); + } }); }); }); diff --git a/tst/unit/utils/FaultSuppression.test.ts b/tst/unit/utils/FaultSuppression.test.ts index 6230fba9..79e16646 100644 --- a/tst/unit/utils/FaultSuppression.test.ts +++ b/tst/unit/utils/FaultSuppression.test.ts @@ -1,12 +1,17 @@ import { describe, it, expect } from 'vitest'; -import { hasSuppressFault, markSuppressFault } from '../../../src/utils/FaultSuppression'; +import { + hasSuppressFault, + markIfClientError, + markSuppressFault, + SUPPRESS_FAULT, +} from '../../../src/utils/FaultSuppression'; describe('FaultSuppression', () => { describe('markSuppressFault', () => { it('should tag the error with suppressFault', () => { const error = new Error('test'); markSuppressFault(error); - expect((error as any).suppressFault).toBe(true); + expect((error as any)[SUPPRESS_FAULT]).toBe(true); }); it('should preserve the error type', () => { @@ -18,6 +23,16 @@ describe('FaultSuppression', () => { expect(error).toBeInstanceOf(CustomError); expect(error.code).toBe('CUSTOM'); }); + + it('is a no-op for null', () => { + expect(() => markSuppressFault(null)).not.toThrow(); + }); + + it('is a no-op for non-object values', () => { + expect(() => markSuppressFault('string')).not.toThrow(); + expect(() => markSuppressFault(42)).not.toThrow(); + expect(() => markSuppressFault(undefined)).not.toThrow(); + }); }); describe('hasSuppressFault', () => { @@ -43,4 +58,82 @@ describe('FaultSuppression', () => { expect(hasSuppressFault('string error')).toBe(false); }); }); + + describe('markIfClientError', () => { + it('marks AWS credentials errors (ExpiredTokenException)', () => { + const error = Object.assign(new Error('expired'), { name: 'ExpiredTokenException' }); + markIfClientError(error); + expect(hasSuppressFault(error)).toBe(true); + }); + + it('marks AWS networking errors (NetworkingError)', () => { + const error = Object.assign(new Error('connect failed'), { name: 'NetworkingError' }); + markIfClientError(error); + expect(hasSuppressFault(error)).toBe(true); + }); + + it('marks AWS permission errors (AccessDeniedException)', () => { + const error = Object.assign(new Error('not authorized'), { + name: 'AccessDeniedException', + $metadata: { httpStatusCode: 403 }, + }); + markIfClientError(error); + expect(hasSuppressFault(error)).toBe(true); + }); + + it('marks AWS 4xx service errors', () => { + const error = Object.assign(new Error('not found'), { + name: 'ResourceNotFoundException', + $metadata: { httpStatusCode: 404 }, + }); + markIfClientError(error); + expect(hasSuppressFault(error)).toBe(true); + }); + + it('does not mark AWS throttling errors (429), which are retryable, not client mistakes', () => { + const error = Object.assign(new Error('rate exceeded'), { + name: 'ThrottlingException', + $metadata: { httpStatusCode: 429 }, + }); + markIfClientError(error); + expect(hasSuppressFault(error)).toBe(false); + }); + + it('marks client-side network errors detected via isClientNetworkError (ECONNRESET)', () => { + const error = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); + markIfClientError(error); + expect(hasSuppressFault(error)).toBe(true); + }); + + it('marks SSL certificate errors', () => { + const error = new Error('unable to get local issuer certificate'); + markIfClientError(error); + expect(hasSuppressFault(error)).toBe(true); + }); + + it('does not mark AWS 5xx service errors', () => { + const error = Object.assign(new Error('boom'), { + name: 'InternalServerError', + $metadata: { httpStatusCode: 500 }, + }); + markIfClientError(error); + expect(hasSuppressFault(error)).toBe(false); + }); + + it('does not mark errors with no AWS metadata and no client-network signature', () => { + const error = new Error('some unrelated bug'); + markIfClientError(error); + expect(hasSuppressFault(error)).toBe(false); + }); + + it('is a no-op for null', () => { + expect(() => markIfClientError(null)).not.toThrow(); + }); + + it('is a no-op for primitive values', () => { + expect(() => markIfClientError('ECONNRESET')).not.toThrow(); + expect(() => markIfClientError(undefined)).not.toThrow(); + expect(() => markIfClientError(42)).not.toThrow(); + }); + }); }); diff --git a/tst/unit/utils/Sanitizer.test.ts b/tst/unit/utils/Sanitizer.test.ts new file mode 100644 index 00000000..a0142615 --- /dev/null +++ b/tst/unit/utils/Sanitizer.test.ts @@ -0,0 +1,179 @@ +import { homedir, hostname } from 'os'; +import { describe, expect, test } from 'vitest'; +import { sanitizeMessage, sensitiveInfo } from '../../../src/utils/Sanitizer'; + +describe('Sanitizer', () => { + describe('sensitiveInfo', () => { + test('includes the machine hostname', () => { + expect(sensitiveInfo()).toContain(hostname()); + }); + + test('includes the user home directory', () => { + expect(sensitiveInfo()).toContain(homedir()); + }); + + test('returns the same cached array on subsequent calls', () => { + const first = sensitiveInfo(); + const second = sensitiveInfo(); + expect(second).toBe(first); + }); + + test('does not contain single-character path segments', () => { + for (const word of sensitiveInfo()) { + expect(word.length).toBeGreaterThan(1); + } + }); + + test('returns a non-empty array of strings', () => { + const words = sensitiveInfo(); + expect(words.length).toBeGreaterThan(0); + for (const word of words) { + expect(typeof word).toBe('string'); + } + }); + }); + + describe('sanitizeMessage - basic input handling', () => { + test('returns empty string unchanged', () => { + expect(sanitizeMessage('')).toBe(''); + }); + + test('trims leading and trailing whitespace', () => { + expect(sanitizeMessage(' hello world ')).toBe('hello world'); + }); + + test('trims each line of a multi-line message', () => { + const input = ' line one \n line two \n line three '; + expect(sanitizeMessage(input)).toBe('line one\nline two\nline three'); + }); + + test('preserves multi-line structure', () => { + const input = 'first\nsecond\nthird'; + expect(sanitizeMessage(input)).toBe('first\nsecond\nthird'); + }); + + test('passes through text with no sensitive content', () => { + expect(sanitizeMessage('a benign log line')).toBe('a benign log line'); + }); + }); + + describe('sanitizeMessage - path normalization', () => { + test('converts double-escaped backslashes to forward slashes', () => { + expect(sanitizeMessage(String.raw`D:\\app\\bin\\file.ts`)).toBe('D:/app/bin/file.ts'); + }); + + test('converts single backslashes to forward slashes', () => { + const input = 'D:\\app\\bin\\file.ts'; + expect(sanitizeMessage(input)).toBe('D:/app/bin/file.ts'); + }); + + test('handles mixed forward and backslashes', () => { + const input = 'D:\\app/bin\\file.ts'; + expect(sanitizeMessage(input)).toBe('D:/app/bin/file.ts'); + }); + }); + + describe('sanitizeMessage - sensitive identity redaction', () => { + test('redacts the machine hostname', () => { + const input = `error reported on ${hostname()} during startup`; + const result = sanitizeMessage(input); + expect(result).not.toContain(hostname()); + expect(result).toContain('[*]'); + }); + + test('redacts the user home directory', () => { + const input = `failed to read ${homedir()}/config.json`; + const result = sanitizeMessage(input); + expect(result).not.toContain(homedir()); + expect(result).toContain('[*]'); + }); + + test('does not redact the allowlisted "aws" segment', () => { + // 'aws' would otherwise be redacted as a __dirname segment, since the install path contains it. + expect(sanitizeMessage('connecting to aws region us-east-1')).toBe('connecting to aws region us-east-1'); + }); + + test('does not redact the allowlisted "cloudformation-languageserver" segment', () => { + const input = 'failed in cloudformation-languageserver during init'; + expect(sanitizeMessage(input)).toBe(input); + }); + }); + + describe('sanitizeMessage - ARN and account-id redaction', () => { + test('redacts an IAM user ARN with account id', () => { + expect(sanitizeMessage('User arn:aws:iam::123456789012:user/test-user is not authorized')).toBe( + 'User arn:aws: is not authorized', + ); + }); + + test('redacts an STS assumed-role ARN', () => { + expect(sanitizeMessage('arn:aws:sts::123456789012:assumed-role/MyRole/session-name')).toBe( + 'arn:aws:', + ); + }); + + test('redacts a regional EC2 ARN', () => { + expect(sanitizeMessage('arn:aws:ec2:us-east-1:123456789012:instance/i-0abcdef1234567890')).toBe( + 'arn:aws:', + ); + }); + + test('redacts an aws-cn partition ARN', () => { + expect(sanitizeMessage('arn:aws-cn:lambda:cn-north-1:123456789012:function:my-func')).toBe( + 'arn:aws:', + ); + }); + + test('redacts an aws-us-gov partition ARN', () => { + expect(sanitizeMessage('arn:aws-us-gov:rds:us-gov-west-1:123456789012:db:my-db')).toBe( + 'arn:aws:', + ); + }); + + test('redacts an aws-iso partition ARN', () => { + expect(sanitizeMessage('arn:aws-iso:ec2:us-iso-east-1:123456789012:instance/i-abc')).toBe( + 'arn:aws:', + ); + }); + + test('does not redact an S3 ARN with no account id', () => { + expect(sanitizeMessage('arn:aws:s3:::my-bucket')).toBe('arn:aws:s3:::my-bucket'); + }); + + test('redacts multiple ARNs in the same line', () => { + expect( + sanitizeMessage( + 'arn:aws:iam::111111111111:user/user-a cannot access arn:aws:iam::222222222222:role/role-b', + ), + ).toBe('arn:aws: cannot access arn:aws:'); + }); + + test('redacts a standalone 12-digit account id', () => { + expect(sanitizeMessage('Account 123456789012 not found')).toBe('Account not found'); + }); + + test('does not redact 11-digit numbers', () => { + expect(sanitizeMessage('Account 12345678901 not found')).toBe('Account 12345678901 not found'); + }); + + test('does not redact 13-digit numbers', () => { + expect(sanitizeMessage('Account 1234567890123 not found')).toBe('Account 1234567890123 not found'); + }); + + test('redacts only the 12-digit segment, not adjacent digits', () => { + // 13 digits (\d{12}\b) — boundary check ensures we don't redact the inner 12 digits. + expect(sanitizeMessage('id 1234567890123 ok')).toBe('id 1234567890123 ok'); + }); + + test('redacts an account id followed by punctuation', () => { + expect(sanitizeMessage('account=123456789012, region=us-east-1')).toBe( + 'account=, region=us-east-1', + ); + }); + + test('redacts both an account-id-bearing ARN and a separate plain account id', () => { + const input = 'role arn:aws:iam::111111111111:role/admin in account 222222222222'; + expect(sanitizeMessage(input)).toBe('role arn:aws: in account '); + }); + }); +});