diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index e211f94dc..ab949441e 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -5,6 +5,14 @@ import { join } from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const OLD_FETCH = globalThis.fetch +const { closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock } = vi.hoisted( + () => ({ + closeAllConnectionsMock: vi.fn(), + netFetchMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn() + }) +) type SafeStorageMockOptions = { encryptionAvailable?: boolean @@ -84,10 +92,18 @@ function writeMultiSiteFiles( async function loadClientModule(options: SafeStorageMockOptions = {}) { vi.resetModules() vi.doMock('electron', () => ({ + net: { fetch: netFetchMock }, safeStorage: { isEncryptionAvailable: () => options.encryptionAvailable ?? false, encryptString: (value: string) => Buffer.from(value), decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8')) + }, + session: { + defaultSession: { + closeAllConnections: closeAllConnectionsMock, + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } } })) vi.doMock('os', async () => { @@ -103,6 +119,11 @@ beforeEach(() => { fetchMock = vi.fn(async () => { throw new Error('fetch should not be called') }) + netFetchMock.mockReset() + resolveProxyMock.mockReset() + setProxyMock.mockReset() + closeAllConnectionsMock.mockReset() + resolveProxyMock.mockResolvedValue('DIRECT') globalThis.fetch = fetchMock as typeof fetch vi.restoreAllMocks() }) @@ -115,7 +136,7 @@ describe('Jira client credential storage', () => { it('preserves plaintext fallback and reaches Jira auth header construction', async () => { const siteId = 'site-alpha' writeJiraFiles(siteId, 'token-alpha') - fetchMock.mockResolvedValueOnce( + netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ accountId: 'account-alpha', @@ -137,7 +158,13 @@ describe('Jira client credential storage', () => { viewer: { displayName: 'Ada' } }) - const headers = fetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(fetchMock).not.toHaveBeenCalled() + expect(resolveProxyMock).toHaveBeenCalledWith('https://example.atlassian.net/rest/api/3/myself') + expect(netFetchMock).toHaveBeenCalledWith( + 'https://example.atlassian.net/rest/api/3/myself', + expect.objectContaining({ headers: expect.any(Headers) }) + ) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers expect(headers.get('Authorization')).toBe( `Basic ${Buffer.from('ada@example.com:token-alpha').toString('base64')}` ) @@ -194,7 +221,7 @@ describe('Jira client credential storage', () => { const siteId = 'site-alpha' const tokenPath = tokenPathForSite(siteId) writeJiraFiles(siteId, 'token-revoked') - fetchMock.mockResolvedValueOnce( + netFetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ errorMessages: ['Jira authentication failed'] }), { status: 401, statusText: 'Unauthorized', @@ -213,6 +240,7 @@ describe('Jira client credential storage', () => { error: 'Jira authentication failed' }) + expect(fetchMock).not.toHaveBeenCalled() expect(existsSync(tokenPath)).toBe(true) expect(jira.getStatus()).toMatchObject({ connected: true, @@ -224,7 +252,7 @@ describe('Jira client credential storage', () => { const siteId = 'site-alpha' let keychainApproved = false writeJiraFiles(siteId, Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe])) - fetchMock.mockResolvedValueOnce( + netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ accountId: 'account-alpha', @@ -252,6 +280,7 @@ describe('Jira client credential storage', () => { ok: true, viewer: { displayName: 'Ada' } }) + expect(fetchMock).not.toHaveBeenCalled() expect(jira.getStatus().credentialError).toBeUndefined() }) @@ -309,4 +338,55 @@ describe('Jira client credential storage', () => { expect(() => jira.getClients('bad')).toThrow('Could not decrypt') }) + + it('does not clear credentials when Electron transport fails after a network change', async () => { + const siteId = 'site-alpha' + const tokenPath = tokenPathForSite(siteId) + writeJiraFiles(siteId, 'token-alpha') + netFetchMock.mockRejectedValueOnce( + new TypeError('fetch failed', { + cause: new Error('socket disconnected') + }) + ) + const jira = await loadClientModule() + + await expect(jira.testConnection(siteId)).resolves.toEqual({ + ok: false, + error: 'fetch failed' + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(existsSync(tokenPath)).toBe(true) + expect(jira.getStatus()).toMatchObject({ + connected: true, + sites: [{ id: siteId }] + }) + }) + + it('bridges proxy environment settings before Jira connect requests', async () => { + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + accountId: 'account-alpha', + displayName: 'Ada', + emailAddress: 'ada@example.com' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'example.atlassian.net', + email: 'ada@example.com', + apiToken: 'token-alpha' + }) + ).resolves.toMatchObject({ ok: true, viewer: { displayName: 'Ada' } }) + + expect(resolveProxyMock).toHaveBeenCalledWith('https://example.atlassian.net/rest/api/3/myself') + expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index a29c8a807..a0e705290 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -5,12 +5,14 @@ import { createHash } from 'crypto' import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs' import { homedir } from 'os' import { join } from 'path' -import { safeStorage } from 'electron' +import { net, safeStorage, session } from 'electron' import { CredentialDecryptionError, credentialFileHasContent, readStoredCredentialToken } from '../integration-credential-file' +import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' +import { withSpan } from '../observability/tracer' import type { JiraConnectArgs, JiraConnectionStatus, @@ -303,6 +305,55 @@ function authHeader(email: string, apiToken: string): string { return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}` } +function describeErrorCause(error: unknown): string | undefined { + if (!error || typeof error !== 'object' || !('cause' in error)) { + return undefined + } + const cause = (error as { cause?: unknown }).cause + if (cause instanceof Error) { + return `${cause.name}: ${cause.message}` + } + return cause === undefined ? undefined : String(cause) +} + +async function jiraFetch(url: string, init: RequestInit): Promise { + return withSpan( + 'jira.request', + async (span) => { + span.setAttribute('jira.siteUrl', new URL(url).origin) + await ensureElectronProxyFromEnvironment({ + proxySession: session.defaultSession, + probeUrl: url + }).catch((error) => { + span.addEvent('jira.proxySetupFailed', { + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error) + }) + }) + try { + // Why: Electron's network stack follows Chromium proxy/session state, + // avoiding undici's stale keep-alive sockets after VPN path changes. + return await net.fetch(url, init) + } catch (error) { + span.setAttribute( + 'jira.transportErrorName', + error instanceof Error ? error.name : typeof error + ) + span.setAttribute( + 'jira.transportErrorMessage', + error instanceof Error ? error.message : String(error) + ) + const cause = describeErrorCause(error) + if (cause) { + span.setAttribute('jira.transportErrorCause', cause) + } + throw error + } + }, + { kind: 'client' } + ) +} + async function requestWithCredentials( siteUrl: string, email: string, @@ -314,7 +365,7 @@ async function requestWithCredentials( headers.set('Accept', 'application/json') headers.set('Content-Type', 'application/json') headers.set('Authorization', authHeader(email, apiToken)) - const response = await fetch(`${siteUrl}${path}`, { + const response = await jiraFetch(`${siteUrl}${path}`, { ...init, headers }) @@ -357,7 +408,7 @@ export async function jiraRequest( headers.set('Accept', 'application/json') headers.set('Content-Type', 'application/json') headers.set('Authorization', client.authorization) - const response = await fetch(`${client.site.siteUrl}${path}`, { + const response = await jiraFetch(`${client.site.siteUrl}${path}`, { ...init, headers })