fix: route Jira requests through Electron networking (#5630)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-17 14:58:33 -07:00 committed by GitHub
parent ee2bc5eb02
commit abc3037b6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 138 additions and 7 deletions

View File

@ -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()
})
})

View File

@ -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<Response> {
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<T>(
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
})