Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/lib/__tests__/api-error-handling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* `handleApiError` builds the message the user actually reads. A certificate
* rejection arrives with no HTTP response, so it used to fall through to a bare
* "Failed to <operation>" — dropping the one detail the user can act on.
*/
import { AxiosError } from 'axios';
import { describe, expect, it, vi } from 'vitest';

vi.mock('@utils/analytics', () => ({
analytics: { captureException: vi.fn() },
}));

import { handleApiError } from '../api';

describe('handleApiError', () => {
it('explains a certificate rejection and how to fix it', () => {
// Verbatim from error tracking issue 019f9654.
const error = Object.assign(
new Error(
'self-signed certificate; if the root CA is installed locally, try running Node.js with --use-system-ca',
),
{ code: 'DEPTH_ZERO_SELF_SIGNED_CERT' },
);

const result = handleApiError(error, 'fetch user data');

expect(result.message).toContain('Could not verify the HTTPS certificate');
expect(result.message).toContain('--use-system-ca');
expect(result.message).toContain('NODE_EXTRA_CA_CERTS');
});

it.each([
[401, 'Authentication failed'],
[403, 'Access denied'],
[404, 'Resource not found'],
])('leaves the %i message alone', (status, expected) => {
const error = new AxiosError('Request failed');
error.response = { status, data: {} } as AxiosError['response'];

const result = handleApiError(error, 'fetch user data');

expect(result.message).toContain(expected);
expect(result.statusCode).toBe(status);
});
});
12 changes: 12 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import axios, { AxiosError } from 'axios';
import { z } from 'zod';
import { analytics } from '@utils/analytics';
import { isTlsTrustError, TLS_TRUST_HINT } from '@utils/network-errors';
import { WIZARD_USER_AGENT } from './constants';

/**
Expand Down Expand Up @@ -262,6 +263,17 @@ export async function fetchSlackConnected(
}

export function handleApiError(error: unknown, operation: string): ApiError {
// Before the axios branches: a certificate rejection is an AxiosError with
// no `response`, so it would otherwise fall through to a bare
// "Failed to <operation>" and strip the one detail the user can act on.
if (isTlsTrustError(error)) {
return new ApiError(
`Could not verify the HTTPS certificate while trying to ${operation}. ${TLS_TRUST_HINT}`,
undefined,
axios.isAxiosError(error) ? error.config?.url : undefined,
);
}

if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<{ detail?: string }>;
const status = axiosError.response?.status;
Expand Down
8 changes: 8 additions & 0 deletions src/ui/tui/screens/SlackConnectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { Program } from '@lib/programs/program-registry';
import { getOrAskForProjectData } from '@utils/setup-utils';
import { analytics } from '@utils/analytics';
import { logToFile } from '@utils/debug';
import { isBenignNetworkError } from '@utils/network-errors';
import { openTrackedLink, withUtm } from '@utils/links';

interface SlackConnectScreenProps {
Expand Down Expand Up @@ -162,6 +163,13 @@ export const SlackConnectScreen = ({ store }: SlackConnectScreenProps) => {
if (store.session.slackConnected === null) {
store.setSlackConnected(false);
}
// A dead network or an intercepting TLS proxy is the user's
// environment, not a wizard bug, and the nudge fallback above
// already handles it — log it and leave error tracking alone.
if (isBenignNetworkError(err)) {
logToFile(`[slack-connect] connected check skipped: ${String(err)}`);
return;
}
analytics.captureException(
err instanceof Error ? err : new Error(String(err)),
{ step: 'slack_connected_check' },
Expand Down
116 changes: 116 additions & 0 deletions src/ui/tui/screens/__tests__/slack-connect-error-reporting.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* The Slack connectivity poll degrades to the nudge copy on any failure, so a
* user whose network is unreachable — or whose corporate proxy intercepts TLS —
* loses nothing. Reporting those throws to error tracking bought us nothing
* either, and buried real wizard bugs under environment noise.
*
* Locks the split: user-network failures stay out of error tracking, genuine
* API failures keep flowing to it.
*/
import { createRequire } from 'module';
import net from 'net';
import React from 'react';
import { render } from 'ink-testing-library';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';

const { captureException, wizardCapture } = vi.hoisted(() => ({
captureException: vi.fn(),
wizardCapture: vi.fn(),
}));

vi.mock('@utils/analytics', () => ({
analytics: { captureException, wizardCapture },
}));

// The suite aliases `ink` to a manual mock globally; this screen needs the real
// reconciler so its effects actually flush.
vi.mock('ink', async () => {
const require_ = createRequire(import.meta.url);
return (await import(require_.resolve('ink'))) as typeof import('ink');
});

vi.mock('@utils/links', () => ({
openTrackedLink: vi.fn(),
withUtm: (url: string) => url,
}));

import { SlackConnectScreen } from '../SlackConnectScreen';

/** A port nothing listens on — the poll's axios call fails with ECONNREFUSED. */
let deadPort = 0;
/** A server that answers every request with 401 — a real, reportable failure. */
let unauthorized: net.Server;
let unauthorizedPort = 0;

beforeAll(async () => {
const probe = net.createServer();
await new Promise<void>((resolve) => probe.listen(0, resolve));
deadPort = (probe.address() as net.AddressInfo).port;
await new Promise<void>((resolve) => probe.close(() => resolve()));

const http = await import('http');
unauthorized = http.createServer((_req, res) => {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end('{"detail":"Invalid token"}');
});
await new Promise<void>((resolve) => unauthorized.listen(0, () => resolve()));
unauthorizedPort = (unauthorized.address() as net.AddressInfo).port;
});

afterAll(async () => {
await new Promise<void>((resolve) => unauthorized.close(() => resolve()));
});

function buildStore(port: number) {
const session = {
credentials: {
accessToken: 'token',
projectId: 1,
host: { apiHost: `http://127.0.0.1:${port}` },
},
slackConnected: null as boolean | null,
roleAtOrganization: 'engineer',
loginUrl: null,
};
return {
session,
subscribe: () => () => undefined,
getSnapshot: () => 1,
setSlackConnected: (value: boolean) => {
session.slackConnected = value;
},
setSlackStepDismissed: vi.fn(),
setLoginUrl: vi.fn(),
setCredentials: vi.fn(),
setRoleAtOrganization: vi.fn(),
setApiUser: vi.fn(),
};
}

/** One poll tick plus slack — the screen polls immediately on mount. */
async function runOnePoll(port: number) {
const { unmount } = render(
React.createElement(SlackConnectScreen, {
store: buildStore(port) as never,
}),
);
await new Promise((resolve) => setTimeout(resolve, 1200));
unmount();
}

describe('SlackConnectScreen connectivity poll', () => {
it('keeps an unreachable network out of error tracking', async () => {
captureException.mockClear();
await runOnePoll(deadPort);
expect(captureException).not.toHaveBeenCalled();
});

it('still reports a genuine API failure', async () => {
captureException.mockClear();
await runOnePoll(unauthorizedPort);
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureException.mock.calls[0][1]).toEqual({
step: 'slack_connected_check',
});
});
});
127 changes: 127 additions & 0 deletions src/utils/__tests__/network-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { AxiosError } from 'axios';

import {
isBenignNetworkError,
isTlsTrustError,
TLS_TRUST_HINT,
} from '../network-errors';

/** Node/undici attach `code` to an otherwise plain Error. */
function withCode(message: string, code: string): Error {
return Object.assign(new Error(message), { code });
}

/** undici's opaque wrapper: `TypeError: fetch failed` + the real cause. */
function fetchFailed(cause?: unknown): TypeError {
return Object.assign(new TypeError('fetch failed'), { cause });
}

describe('isTlsTrustError', () => {
it('matches the exact error the wizard captured in production', () => {
// Verbatim from error tracking issue 019f9654 (step slack_connected_check,
// screen slack-connect) — Node >= 22 reports the sentence, not the code.
const err = new Error(
'self-signed certificate; if the root CA is installed locally, try running Node.js with --use-system-ca',
);
expect(isTlsTrustError(err)).toBe(true);
expect(isBenignNetworkError(err)).toBe(true);
});

it.each([
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'SELF_SIGNED_CERT_IN_CHAIN',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'CERT_HAS_EXPIRED',
'ERR_TLS_CERT_ALTNAME_INVALID',
])('matches OpenSSL code %s', (code) => {
expect(isTlsTrustError(withCode('handshake failed', code))).toBe(true);
});

it('matches a TLS cause nested behind undici fetch failed', () => {
const err = fetchFailed(
withCode(
'unable to verify the first certificate',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
),
);
expect(isTlsTrustError(err)).toBe(true);
});

it('does not match a plain connection refusal', () => {
expect(isTlsTrustError(withCode('connect', 'ECONNREFUSED'))).toBe(false);
});
});

describe('isBenignNetworkError', () => {
it.each([
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'ENOTFOUND',
'EAI_AGAIN',
'ERR_NETWORK',
'UND_ERR_CONNECT_TIMEOUT',
])('matches transport code %s', (code) => {
expect(isBenignNetworkError(withCode('boom', code))).toBe(true);
});

it.each(['SocketError', 'ConnectTimeoutError', 'HeadersTimeoutError'])(
'matches undici error named %s',
(name) => {
const err = new Error('other side closed');
err.name = name;
expect(isBenignNetworkError(err)).toBe(true);
},
);

it('matches a bare undici fetch failure with no usable cause', () => {
expect(isBenignNetworkError(fetchFailed())).toBe(true);
});

it('matches an axios error wrapping a transport code', () => {
expect(
isBenignNetworkError(new AxiosError('connect', 'ECONNREFUSED')),
).toBe(true);
});

// The whole point of the classifier: real problems must keep flowing to
// error tracking. A response the server actually sent is not "the network".
it('does not match an HTTP error response', () => {
const err = new AxiosError('Request failed with status code 401');
err.response = { status: 401 } as AxiosError['response'];
expect(isBenignNetworkError(err)).toBe(false);
});

it('does not match an ordinary programming error', () => {
expect(isBenignNetworkError(new TypeError('x is not a function'))).toBe(
false,
);
expect(isBenignNetworkError(new Error('Invalid response format'))).toBe(
false,
);
});

it('does not match a benign filesystem errno', () => {
expect(isBenignNetworkError(withCode('no entry', 'ENOENT'))).toBe(false);
});

it.each([null, undefined, 'a string', 42])('tolerates %p', (value) => {
expect(isBenignNetworkError(value)).toBe(false);
expect(isTlsTrustError(value)).toBe(false);
});

it('terminates on a self-referential cause chain', () => {
const err: Error & { cause?: unknown } = new Error('loop');
err.cause = err;
expect(isBenignNetworkError(err)).toBe(false);
});
});

describe('TLS_TRUST_HINT', () => {
it('names both remediation routes', () => {
expect(TLS_TRUST_HINT).toContain('--use-system-ca');
expect(TLS_TRUST_HINT).toContain('NODE_EXTRA_CA_CERTS');
});
});
16 changes: 12 additions & 4 deletions src/utils/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { IS_PRODUCTION_BUILD, RUN_SURFACE, TASK_ID, TASK_RUN_ID } from '@env';
import { VERSION } from '@lib/version';
import { debug, logToFile } from './debug';
import { applyCiFlagOverrides } from './ci-flag-overrides';
import { isBenignNetworkError } from './network-errors';

/**
* The invocation, reduced to flag-safe strings: the command word (first
Expand Down Expand Up @@ -323,10 +324,17 @@ export class Analytics {
}
} catch (error) {
debug('Failed to get all feature flags:', error);
this.captureException(
error instanceof Error ? error : new Error(String(error)),
{ step: 'get_all_flags' },
);
// Same reasoning as the Slack connectivity poll: an unreachable network
// or an intercepting TLS proxy is the user's environment, and the run
// already continues on defaults, so it isn't worth an exception.
if (isBenignNetworkError(error)) {
logToFile('[flags] evaluation skipped (network):', String(error));
} else {
this.captureException(
error instanceof Error ? error : new Error(String(error)),
{ step: 'get_all_flags' },
);
}
}
// Outside the fetch guard on purpose: a malformed CI override must fail
// the run loudly, and a valid one applies even when the fetch failed —
Expand Down
Loading
Loading