fix: route Jira requests through Electron networking (#5630)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
ee2bc5eb02
commit
abc3037b6a
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue