mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(jira): support scoped Cloud API tokens (#28604)
Support scoped Jira Cloud API tokens through the configured gateway while keeping self-hosted routing, redirects, diagnostics, and selected-host capabilities bounded. Co-authored-by: Jerome Würf <oss@scharom.de>
This commit is contained in:
@@ -16,8 +16,14 @@ Jira sits next to GitHub and Linear in the task drawer. Browse Jira issues, upda
|
||||
**Cloud**
|
||||
|
||||
- **Jira Cloud site URL** — e.g. `https://example.atlassian.net`.
|
||||
- **Atlassian email** — the address on your Atlassian account.
|
||||
- **Atlassian API token** — create one at [id.atlassian.com → Security → API tokens](https://id.atlassian.com/manage-profile/security/api-tokens).
|
||||
- **Atlassian email** — the address on your Atlassian account. Optional for scoped tokens: leave it empty to use Bearer authentication.
|
||||
- **Atlassian API token** — create one at [id.atlassian.com → Security → API tokens](https://id.atlassian.com/manage-profile/security/api-tokens), with or without scopes.
|
||||
|
||||
Scoped tokens need `read:jira-work` to browse issues, `write:jira-work` to create or edit issues and comments, and `read:jira-user` to verify the account and choose assignees. Board column ordering additionally needs `read:board-scope:jira-software`, `read:board-scope.admin:jira-software`, and `read:project:jira`; without these, statuses sort alphabetically.
|
||||
|
||||
Orca looks up the site's cloud ID without sending the token, then routes scoped-token API requests through `https://api.atlassian.com/ex/jira/<cloudId>`. With an email, it tries the site first and retries on the gateway only after a 401. Browse links keep using the site URL. A missing scope reports the endpoint and required access without removing the saved credential.
|
||||
|
||||
For SSH connections, the remote runtime owns the token and API requests. Older remote runtimes still require an email and an unscoped token; update the remote runtime to enable scoped tokens.
|
||||
|
||||
**Self-hosted**
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type * as Os from 'node:os'
|
||||
import { vi, type Mock } from 'vitest'
|
||||
|
||||
export type JiraClientMocks = {
|
||||
closeAllConnectionsMock: Mock
|
||||
netFetchMock: Mock
|
||||
resolveProxyMock: Mock
|
||||
setProxyMock: Mock
|
||||
}
|
||||
|
||||
export type SafeStorageMockOptions = {
|
||||
encryptionAvailable?: boolean
|
||||
decryptString?: (value: Buffer) => string
|
||||
}
|
||||
|
||||
export async function loadJiraClientModule(
|
||||
mocks: JiraClientMocks,
|
||||
tempHome: string,
|
||||
options: SafeStorageMockOptions = {}
|
||||
) {
|
||||
const { closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock } = mocks
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
session: {
|
||||
defaultSession: {
|
||||
closeAllConnections: closeAllConnectionsMock,
|
||||
resolveProxy: resolveProxyMock,
|
||||
setProxy: setProxyMock
|
||||
}
|
||||
}
|
||||
}))
|
||||
// Why here and not in beforeEach: vi.resetModules() above gives the http-client module
|
||||
// a fresh singleton, so the port must be installed on that instance. The electron net
|
||||
// mock alone is inert now that Jira fetches through the port.
|
||||
const { setMainHttpClient } = await import('../../network/http-client')
|
||||
setMainHttpClient({
|
||||
fetch: (url, init) => netFetchMock(url, init),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These are the only session methods the proxy fixture exercises.
|
||||
proxySession: () => ({ resolveProxy: resolveProxyMock, setProxy: setProxyMock }) as never
|
||||
})
|
||||
const { setSecretStore } = await import('../../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: options.decryptString ?? ((value) => value.toString('utf-8')),
|
||||
describeProtectionGap: () => null
|
||||
})
|
||||
vi.doMock('os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
})
|
||||
|
||||
// One import call per reset so the split modules share a single graph (and
|
||||
// thus one copy of the request queue / credential caches) per test.
|
||||
const [client, queue, api] = await Promise.all([
|
||||
import('../client'),
|
||||
import('../request-queue'),
|
||||
import('../authenticated-request')
|
||||
])
|
||||
return { ...client, ...queue, ...api }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
|
||||
export type JiraFixtureRequest = {
|
||||
url: string
|
||||
authorization: string | undefined
|
||||
method: string | undefined
|
||||
}
|
||||
|
||||
export async function startJiraAuthFixture(
|
||||
respond: (request: JiraFixtureRequest, response: ServerResponse, raw: IncomingMessage) => void
|
||||
) {
|
||||
const requests: JiraFixtureRequest[] = []
|
||||
const server = createServer((raw, response) => {
|
||||
const header = raw.headers['x-jira-fixture-url']
|
||||
const request = {
|
||||
url: typeof header === 'string' ? header : '',
|
||||
authorization: raw.headers.authorization,
|
||||
method: raw.method
|
||||
}
|
||||
requests.push(request)
|
||||
respond(request, response, raw)
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Expected a TCP Jira fixture')
|
||||
}
|
||||
return {
|
||||
requests,
|
||||
fetch: (url: string, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set('x-jira-fixture-url', url)
|
||||
return fetch(`http://127.0.0.1:${address.port}${new URL(url).pathname}`, {
|
||||
...init,
|
||||
headers
|
||||
})
|
||||
},
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (!server.listening) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
server.closeAllConnections()
|
||||
server.close((error) =>
|
||||
error && error.message !== 'Server is not running.' ? reject(error) : resolve()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function jiraFixtureJson(response: ServerResponse, status: number, body: unknown): void {
|
||||
response.writeHead(status, { 'Content-Type': 'application/json' })
|
||||
response.end(JSON.stringify(body))
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
|
||||
import { getMainHttpClient } from '../network/http-client'
|
||||
import { withSpan } from '../observability/tracer'
|
||||
import type { JiraAuthType, JiraSite } from '../../shared/jira-types'
|
||||
import { isJiraGatewayBaseUrl } from './gateway-base-url'
|
||||
import { JiraApiError, toJiraApiError } from './jira-api-error'
|
||||
|
||||
export { JiraApiError } from './jira-api-error'
|
||||
|
||||
// Why: Atlassian's XSRF filter rejects POST/PUT REST calls that carry a browser
|
||||
// User-Agent, failing them with "XSRF check failed" even under API-token auth.
|
||||
@@ -22,21 +26,22 @@ export function apiBasePath(site: JiraSite): string {
|
||||
return site.authType === 'server' ? '/rest/api/2' : '/rest/api/3'
|
||||
}
|
||||
|
||||
export class JiraApiError extends Error {
|
||||
status: number | null
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message)
|
||||
this.status = status
|
||||
export function apiBaseUrl(site: JiraSite): string {
|
||||
if (site.authType !== 'server' && site.apiBaseUrl !== undefined) {
|
||||
if (!isJiraGatewayBaseUrl(site.apiBaseUrl)) {
|
||||
throw new JiraApiError('Invalid Jira gateway URL. Reconnect this Jira site.')
|
||||
}
|
||||
return site.apiBaseUrl
|
||||
}
|
||||
return site.siteUrl
|
||||
}
|
||||
|
||||
export function authHeader(email: string, apiToken: string, authType?: JiraAuthType): string {
|
||||
export function authHeader(email: string, apiToken: string, _authType?: JiraAuthType): string {
|
||||
// Self-hosted with no username = a personal access token (Bearer); Basic auth
|
||||
// with a PAT in the password slot is what produces the 401s users report.
|
||||
// Self-hosted WITH a username is classic username+password Basic auth, which
|
||||
// older Server/DC instances (predating PATs) require. Cloud is always Basic.
|
||||
if (authType === 'server' && !email) {
|
||||
// older Server/DC instances require. Email-free Cloud tokens use the gateway.
|
||||
if (!email) {
|
||||
return `Bearer ${apiToken}`
|
||||
}
|
||||
return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`
|
||||
@@ -53,7 +58,7 @@ function describeErrorCause(error: unknown): string | undefined {
|
||||
return cause === undefined ? undefined : String(cause)
|
||||
}
|
||||
|
||||
async function jiraFetch(url: string, init: RequestInit): Promise<Response> {
|
||||
export async function jiraFetch(url: string, init: RequestInit): Promise<Response> {
|
||||
return withSpan(
|
||||
'jira.request',
|
||||
async (span) => {
|
||||
@@ -109,10 +114,11 @@ export async function requestWithCredentials(
|
||||
headers.set('Authorization', authHeader(email, apiToken, authType))
|
||||
const response = await jiraFetch(`${siteUrl}${path}`, {
|
||||
...init,
|
||||
headers
|
||||
headers,
|
||||
...(isJiraGatewayBaseUrl(siteUrl) ? { redirect: 'error' as const } : {})
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new JiraApiError(await readJiraError(response), response.status)
|
||||
throw await toJiraApiError(response, `${siteUrl}${path}`)
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return null
|
||||
@@ -120,27 +126,6 @@ export async function requestWithCredentials(
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function readJiraError(response: Response): Promise<string> {
|
||||
try {
|
||||
const data = (await response.json()) as {
|
||||
errorMessages?: string[]
|
||||
errors?: Record<string, string>
|
||||
message?: string
|
||||
}
|
||||
const messages = [
|
||||
...(Array.isArray(data.errorMessages) ? data.errorMessages : []),
|
||||
...Object.values(data.errors ?? {}),
|
||||
...(data.message ? [data.message] : [])
|
||||
].filter(Boolean)
|
||||
if (messages.length > 0) {
|
||||
return messages.join('; ')
|
||||
}
|
||||
} catch {
|
||||
// Fall through to status text.
|
||||
}
|
||||
return response.statusText || `Jira request failed (${response.status})`
|
||||
}
|
||||
|
||||
export async function jiraRequest<T>(
|
||||
client: JiraClientForSite,
|
||||
path: string,
|
||||
@@ -151,12 +136,14 @@ export async function jiraRequest<T>(
|
||||
headers.set('Content-Type', 'application/json')
|
||||
headers.set('User-Agent', JIRA_API_USER_AGENT)
|
||||
headers.set('Authorization', client.authorization)
|
||||
const response = await jiraFetch(`${client.site.siteUrl}${path}`, {
|
||||
const url = `${apiBaseUrl(client.site)}${path}`
|
||||
const response = await jiraFetch(url, {
|
||||
...init,
|
||||
headers
|
||||
headers,
|
||||
...(isJiraGatewayBaseUrl(apiBaseUrl(client.site)) ? { redirect: 'error' as const } : {})
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new JiraApiError(await readJiraError(response), response.status)
|
||||
throw await toJiraApiError(response, url)
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return null as T
|
||||
@@ -164,28 +151,47 @@ export async function jiraRequest<T>(
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
function resolveBinaryRequestUrl(site: JiraSite, pathOrUrl: string): URL {
|
||||
const siteUrl = new URL(site.siteUrl)
|
||||
const baseUrl = apiBaseUrl(site)
|
||||
const requestUrl = /^https?:\/\//i.test(pathOrUrl)
|
||||
? new URL(pathOrUrl)
|
||||
: new URL(`${site.siteUrl}${pathOrUrl}`)
|
||||
if (!requestUrl.username && !requestUrl.password) {
|
||||
if (baseUrl !== site.siteUrl && requestUrl.href.startsWith(`${baseUrl}/`)) {
|
||||
return requestUrl
|
||||
}
|
||||
if (requestUrl.origin === siteUrl.origin) {
|
||||
if (baseUrl === site.siteUrl) {
|
||||
return requestUrl
|
||||
}
|
||||
if (requestUrl.pathname.startsWith('/rest/')) {
|
||||
return new URL(`${baseUrl}${requestUrl.pathname}${requestUrl.search}`)
|
||||
}
|
||||
throw new JiraApiError('Scoped Jira attachments require a REST content URL.', null)
|
||||
}
|
||||
}
|
||||
// The gateway origin is shared by every tenant, so its full cloud-ID prefix must match.
|
||||
throw new JiraApiError('Jira attachment URL must use the configured site origin.', null)
|
||||
}
|
||||
|
||||
export async function jiraRequestBinary(
|
||||
client: JiraClientForSite,
|
||||
pathOrUrl: string
|
||||
): Promise<{ data: ArrayBuffer; contentType: string }> {
|
||||
const siteUrl = new URL(client.site.siteUrl)
|
||||
const requestUrl = /^https?:\/\//i.test(pathOrUrl)
|
||||
? new URL(pathOrUrl)
|
||||
: new URL(`${client.site.siteUrl}${pathOrUrl}`)
|
||||
if (requestUrl.origin !== siteUrl.origin) {
|
||||
// Why: attachment metadata is provider-controlled; never forward Jira
|
||||
// credentials if a malformed response points at another origin.
|
||||
throw new JiraApiError('Jira attachment URL must use the configured site origin.', null)
|
||||
}
|
||||
const requestUrl = resolveBinaryRequestUrl(client.site, pathOrUrl)
|
||||
const headers = new Headers()
|
||||
// Why: attachment content is binary; forcing JSON Accept/Content-Type can
|
||||
// break downloads and confuses some Atlassian edge responses.
|
||||
headers.set('Accept', '*/*')
|
||||
headers.set('User-Agent', JIRA_API_USER_AGENT)
|
||||
headers.set('Authorization', client.authorization)
|
||||
const response = await jiraFetch(requestUrl.toString(), { headers })
|
||||
const response = await jiraFetch(requestUrl.toString(), {
|
||||
headers,
|
||||
...(isJiraGatewayBaseUrl(apiBaseUrl(client.site)) ? { redirect: 'error' as const } : {})
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new JiraApiError(await readJiraError(response), response.status)
|
||||
throw await toJiraApiError(response, requestUrl.toString())
|
||||
}
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadJiraClientModule } from './__mocks__/jira-client-module'
|
||||
import { jiraFixtureJson, startJiraAuthFixture } from './auth-http-fixture'
|
||||
|
||||
const SITE = 'https://example.atlassian.net'
|
||||
const GATEWAY = 'https://api.atlassian.com/ex/jira/cloud-abc'
|
||||
const VIEWER = { accountId: 'account-alpha', displayName: 'Ada' }
|
||||
const CREDENTIAL = { siteUrl: SITE, email: 'ada@example.com', apiToken: 'private-token' }
|
||||
const mocks = vi.hoisted(() => ({
|
||||
closeAllConnectionsMock: vi.fn(),
|
||||
netFetchMock: vi.fn(),
|
||||
resolveProxyMock: vi.fn(),
|
||||
setProxyMock: vi.fn()
|
||||
}))
|
||||
let tempHome = ''
|
||||
let fixture: Awaited<ReturnType<typeof startJiraAuthFixture>> | undefined
|
||||
|
||||
async function loadClient() {
|
||||
const jira = await loadJiraClientModule(mocks, tempHome, { encryptionAvailable: true })
|
||||
const { setMainHttpClient } = await import('../network/http-client')
|
||||
setMainHttpClient({
|
||||
fetch: (url, init) => {
|
||||
if (!fixture) {
|
||||
throw new Error('Start the Jira HTTP fixture first')
|
||||
}
|
||||
return fixture.fetch(url, init)
|
||||
},
|
||||
proxySession: () => null
|
||||
})
|
||||
return jira
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), 'orca-jira-scoped-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fixture?.close()
|
||||
fixture = undefined
|
||||
rmSync(tempHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('scoped Jira Cloud tokens over HTTP', () => {
|
||||
it('refuses redirects from gateway credential, API and attachment requests', async () => {
|
||||
fixture = await startJiraAuthFixture((request, response) => {
|
||||
if (request.url.endsWith('/_edge/tenant_info')) {
|
||||
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
|
||||
} else {
|
||||
response.writeHead(302, { Location: `${GATEWAY}/other-tenant` })
|
||||
response.end()
|
||||
}
|
||||
})
|
||||
const jira = await loadClient()
|
||||
await expect(jira.connect({ ...CREDENTIAL, email: '' })).resolves.toMatchObject({ ok: false })
|
||||
expect(fixture.requests).toHaveLength(2)
|
||||
const client = {
|
||||
site: {
|
||||
id: 'scoped',
|
||||
siteUrl: SITE,
|
||||
email: '',
|
||||
displayName: 'Ada',
|
||||
accountId: 'account-alpha',
|
||||
authType: 'cloud' as const,
|
||||
apiBaseUrl: GATEWAY
|
||||
},
|
||||
authorization: 'Bearer private-token'
|
||||
}
|
||||
await expect(jira.jiraRequest(client, '/rest/api/3/issue/ALP-1')).rejects.toThrow()
|
||||
await expect(
|
||||
jira.jiraRequestBinary(client, `${SITE}/rest/api/3/attachment/content/1`)
|
||||
).rejects.toThrow()
|
||||
expect(fixture.requests).toHaveLength(4)
|
||||
expect(fixture.requests.every((request) => !request.url.endsWith('/other-tenant'))).toBe(true)
|
||||
})
|
||||
|
||||
it('revokes cached attachment bytes only after replacing a verified credential', async () => {
|
||||
let authorized = true
|
||||
fixture = await startJiraAuthFixture((request, response) => {
|
||||
if (request.url.endsWith('/_edge/tenant_info')) {
|
||||
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
|
||||
} else {
|
||||
jiraFixtureJson(response, authorized ? 200 : 401, authorized ? VIEWER : {})
|
||||
}
|
||||
})
|
||||
const jira = await loadClient()
|
||||
await jira.connect(CREDENTIAL)
|
||||
const client = jira.getClients()[0]
|
||||
if (!client) {
|
||||
throw new Error('Expected connected Jira client')
|
||||
}
|
||||
const cache = await import('./attachment-image-cache')
|
||||
cache.setCachedAttachmentDataUrl({
|
||||
siteId: client.site.id,
|
||||
attachmentId: 'image',
|
||||
dataUrl: 'data:image/png;base64,AQ==',
|
||||
byteSize: 1
|
||||
})
|
||||
authorized = false
|
||||
await expect(
|
||||
jira.connect({ ...CREDENTIAL, apiToken: 'rejected-token' })
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
expect(cache.getCachedAttachmentDataUrl(client.site.id, 'image')).not.toBeNull()
|
||||
authorized = true
|
||||
await expect(
|
||||
jira.connect({ ...CREDENTIAL, apiToken: 'replacement-token' })
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
expect(cache.getCachedAttachmentDataUrl(client.site.id, 'image')).toBeNull()
|
||||
expect(jira.getClients()[0]?.authorization).toBe(
|
||||
`Basic ${Buffer.from('ada@example.com:replacement-token').toString('base64')}`
|
||||
)
|
||||
cache._resetAttachmentImageCache()
|
||||
})
|
||||
|
||||
it('preserves the installation path in self-hosted attachment URLs', async () => {
|
||||
fixture = await startJiraAuthFixture((_request, response) => {
|
||||
jiraFixtureJson(response, 200, { image: true })
|
||||
})
|
||||
const jira = await loadClient()
|
||||
const attachmentUrl = `${SITE}/jira/secure/attachment/1/shot.png?download=true`
|
||||
await jira.jiraRequestBinary(
|
||||
{
|
||||
site: {
|
||||
id: 'server',
|
||||
siteUrl: `${SITE}/jira`,
|
||||
email: '',
|
||||
displayName: 'Ada',
|
||||
accountId: 'ada',
|
||||
authType: 'server'
|
||||
},
|
||||
authorization: 'Bearer private-token'
|
||||
},
|
||||
attachmentUrl
|
||||
)
|
||||
expect(fixture.requests.map((request) => request.url)).toEqual([attachmentUrl])
|
||||
})
|
||||
|
||||
it('resolves the tenant without a credential and connects an email-free Bearer token', async () => {
|
||||
fixture = await startJiraAuthFixture((request, response) => {
|
||||
if (request.url === `${SITE}/_edge/tenant_info`) {
|
||||
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
|
||||
} else if (request.url === `${GATEWAY}/rest/api/3/myself`) {
|
||||
jiraFixtureJson(response, 200, VIEWER)
|
||||
} else {
|
||||
jiraFixtureJson(response, 401, {})
|
||||
}
|
||||
})
|
||||
const jira = await loadClient()
|
||||
|
||||
await expect(jira.connect({ ...CREDENTIAL, email: '' })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
viewer: VIEWER
|
||||
})
|
||||
expect(fixture.requests).toEqual([
|
||||
{ url: `${SITE}/_edge/tenant_info`, authorization: undefined, method: 'GET' },
|
||||
{
|
||||
url: `${GATEWAY}/rest/api/3/myself`,
|
||||
authorization: 'Bearer private-token',
|
||||
method: 'GET'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('retries site-rejected Basic tokens on the gateway and keeps that route after reload', async () => {
|
||||
fixture = await startJiraAuthFixture((request, response) => {
|
||||
if (request.url === `${SITE}/_edge/tenant_info`) {
|
||||
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
|
||||
} else if (request.url.startsWith(`${GATEWAY}/`)) {
|
||||
jiraFixtureJson(response, 200, VIEWER)
|
||||
} else {
|
||||
jiraFixtureJson(response, 401, { errorMessages: ['Client must be authenticated.'] })
|
||||
}
|
||||
})
|
||||
const jira = await loadClient()
|
||||
|
||||
await expect(jira.connect(CREDENTIAL)).resolves.toMatchObject({ ok: true })
|
||||
const stored = JSON.parse(readFileSync(join(tempHome, '.orca', 'jira-sites.json'), 'utf8'))
|
||||
expect(stored.sites[0]).toMatchObject({
|
||||
siteUrl: SITE,
|
||||
authType: 'cloud',
|
||||
apiBaseUrl: GATEWAY
|
||||
})
|
||||
const reloaded = await loadClient()
|
||||
await expect(reloaded.testConnection()).resolves.toMatchObject({ ok: true })
|
||||
expect(fixture.requests.at(-1)).toMatchObject({
|
||||
url: `${GATEWAY}/rest/api/3/myself`,
|
||||
authorization: `Basic ${Buffer.from('ada@example.com:private-token').toString('base64')}`
|
||||
})
|
||||
expect(reloaded.getStatus()).toMatchObject({ supportsScopedTokens: true })
|
||||
})
|
||||
|
||||
it('routes reads, edits and site attachment links through the selected gateway tenant', async () => {
|
||||
fixture = await startJiraAuthFixture((request, response) => {
|
||||
if (request.url.endsWith('/_edge/tenant_info')) {
|
||||
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
|
||||
} else if (request.url.endsWith('/myself')) {
|
||||
jiraFixtureJson(response, 200, VIEWER)
|
||||
} else {
|
||||
jiraFixtureJson(response, 200, { issues: [] })
|
||||
}
|
||||
})
|
||||
const jira = await loadClient()
|
||||
await jira.connect({ ...CREDENTIAL, email: '' })
|
||||
const client = jira.getClients()[0]
|
||||
if (!client) {
|
||||
throw new Error('Expected connected scoped Jira client')
|
||||
}
|
||||
await jira.jiraRequest(client, '/rest/api/3/issue/ALP-1', { method: 'PUT', body: '{}' })
|
||||
await jira.jiraRequestBinary(client, `${SITE}/rest/api/3/attachment/content/1?redirect=false`)
|
||||
expect(fixture.requests.slice(-2).map((request) => request.url)).toEqual([
|
||||
`${GATEWAY}/rest/api/3/issue/ALP-1`,
|
||||
`${GATEWAY}/rest/api/3/attachment/content/1?redirect=false`
|
||||
])
|
||||
for (const url of [
|
||||
'https://evil.example/attachment.png',
|
||||
`${SITE}/secure/attachment/1/shot.png`,
|
||||
`${SITE}/rest-other/attachment/1/shot.png`,
|
||||
'https://api.atlassian.com/ex/jira/other-cloud/rest/api/3/attachment/content/1',
|
||||
`${GATEWAY}-other/rest/api/3/attachment/content/1`,
|
||||
`${GATEWAY}/../other-cloud/rest/api/3/attachment/content/1`
|
||||
]) {
|
||||
await expect(jira.jiraRequestBinary(client, url)).rejects.toThrow(
|
||||
/configured site|REST content URL/
|
||||
)
|
||||
}
|
||||
expect(fixture.requests).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('retains saved credentials when a later search lacks scopes', async () => {
|
||||
fixture = await startJiraAuthFixture((request, response) => {
|
||||
if (request.url.endsWith('/_edge/tenant_info')) {
|
||||
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
|
||||
} else if (request.url.endsWith('/myself')) {
|
||||
jiraFixtureJson(response, 200, VIEWER)
|
||||
} else {
|
||||
jiraFixtureJson(response, 401, { message: 'Unauthorized; scope does not match' })
|
||||
}
|
||||
})
|
||||
const jira = await loadClient()
|
||||
await jira.connect({ ...CREDENTIAL, email: '' })
|
||||
const { listIssues } = await import('./jira-issue-search')
|
||||
await expect(listIssues()).rejects.toThrow('scope')
|
||||
expect(jira.getStatus().connected).toBe(true)
|
||||
expect(jira.getClients()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('recognizes scope failures in authentication headers without clearing the token', async () => {
|
||||
fixture = await startJiraAuthFixture((_request, response) => {
|
||||
response.setHeader('WWW-Authenticate', 'Bearer error="insufficient_scope"')
|
||||
jiraFixtureJson(response, 401, {})
|
||||
})
|
||||
const jira = await loadClient()
|
||||
const error = await jira
|
||||
.requestWithCredentials(GATEWAY, '', 'private-token', '/rest/api/3/myself')
|
||||
.catch((reason: unknown) => reason)
|
||||
expect(error).toBeInstanceOf(jira.JiraApiError)
|
||||
expect(jira.isAuthError(error)).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies both rejected endpoints safely, including a reflected secret', async () => {
|
||||
fixture = await startJiraAuthFixture((request, response) => {
|
||||
if (request.url.endsWith('/_edge/tenant_info')) {
|
||||
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
|
||||
} else {
|
||||
jiraFixtureJson(response, 401, { message: 'Rejected private-token' })
|
||||
}
|
||||
})
|
||||
const jira = await loadClient()
|
||||
const result = await jira.connect(CREDENTIAL)
|
||||
expect(result).toMatchObject({ ok: false })
|
||||
if (result.ok) {
|
||||
throw new Error('Expected rejected credentials')
|
||||
}
|
||||
expect(result.error).toContain('example.atlassian.net/rest/api/3/myself (HTTP 401)')
|
||||
expect(result.error).toContain('api.atlassian.com')
|
||||
expect(result.error).toContain('email')
|
||||
expect(result.error).not.toContain('private-token')
|
||||
expect(jira.getStatus().connected).toBe(false)
|
||||
})
|
||||
|
||||
it('reports unauthenticated tenant lookup failures without calling a gateway', async () => {
|
||||
fixture = await startJiraAuthFixture((_request, response) => {
|
||||
jiraFixtureJson(response, 404, {})
|
||||
})
|
||||
const jira = await loadClient()
|
||||
const result = await jira.connect({ ...CREDENTIAL, email: '' })
|
||||
expect(result).toMatchObject({ ok: false, error: expect.stringContaining('cloud ID') })
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('HTTP 404') })
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
expect(fixture.requests[0]?.authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('identifies a wrong base path returning HTML as a response problem', async () => {
|
||||
fixture = await startJiraAuthFixture((_request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
response.end('<html>Sign in</html>')
|
||||
})
|
||||
const jira = await loadClient()
|
||||
const result = await jira.connect(CREDENTIAL)
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('unexpected account response')
|
||||
})
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('base path') })
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(['..', 'cloud/other', 'cloud%2fother', '', 'cloud?token=private-token'])(
|
||||
'refuses malformed cloud ID %s before sending credentials to the gateway',
|
||||
async (cloudId) => {
|
||||
fixture = await startJiraAuthFixture((_request, response) => {
|
||||
jiraFixtureJson(response, 200, { cloudId })
|
||||
})
|
||||
const jira = await loadClient()
|
||||
await expect(jira.connect({ ...CREDENTIAL, email: '' })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('cloud ID')
|
||||
})
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('never probes the Cloud gateway for self-hosted credentials or a server outage', async () => {
|
||||
fixture = await startJiraAuthFixture((_request, response) => {
|
||||
jiraFixtureJson(response, 401, {})
|
||||
})
|
||||
const jira = await loadClient()
|
||||
await jira.connect({ ...CREDENTIAL, authType: 'server' })
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
expect(fixture.requests[0]?.url).toBe(`${SITE}/rest/api/2/myself`)
|
||||
await fixture.close()
|
||||
fixture = await startJiraAuthFixture((_request, response) => {
|
||||
jiraFixtureJson(response, 503, {})
|
||||
})
|
||||
await jira.connect(CREDENTIAL)
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps legacy stored sites while refusing edited gateway destinations', async () => {
|
||||
const dir = join(tempHome, '.orca')
|
||||
mkdirSync(join(dir, 'jira-tokens'), { recursive: true })
|
||||
const sites = [
|
||||
{ id: 'legacy' },
|
||||
{ id: 'scoped', apiBaseUrl: GATEWAY },
|
||||
{ id: 'foreign', apiBaseUrl: 'https://evil.example/ex/jira/cloud-abc' },
|
||||
{ id: 'wrong-path', apiBaseUrl: `${GATEWAY}/../other-cloud` },
|
||||
{ id: 'query', apiBaseUrl: `${GATEWAY}?token=private-token` }
|
||||
].map((site) => ({
|
||||
siteUrl: SITE,
|
||||
email: 'ada@example.com',
|
||||
displayName: 'Ada',
|
||||
accountId: 'account-alpha',
|
||||
...site
|
||||
}))
|
||||
writeFileSync(join(dir, 'jira-sites.json'), JSON.stringify({ version: 1, sites }))
|
||||
for (const site of sites) {
|
||||
writeFileSync(
|
||||
join(dir, 'jira-tokens', `${Buffer.from(site.id).toString('base64url')}.enc`),
|
||||
'private-token'
|
||||
)
|
||||
}
|
||||
const jira = await loadClient()
|
||||
expect(jira.getStatus().sites?.map((site) => site.id)).toEqual(['legacy', 'scoped'])
|
||||
expect(jira.getClients()[0]?.site.authType).toBe('cloud')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadJiraClientModule, type SafeStorageMockOptions } from './__mocks__/jira-client-module'
|
||||
|
||||
const OLD_FETCH = globalThis.fetch
|
||||
const { closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock } = vi.hoisted(
|
||||
@@ -14,11 +14,6 @@ const { closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock }
|
||||
})
|
||||
)
|
||||
|
||||
type SafeStorageMockOptions = {
|
||||
encryptionAvailable?: boolean
|
||||
decryptString?: (value: Buffer) => string
|
||||
}
|
||||
|
||||
let tempHome = ''
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -89,46 +84,12 @@ function writeMultiSiteFiles(
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClientModule(options: SafeStorageMockOptions = {}) {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
session: {
|
||||
defaultSession: {
|
||||
closeAllConnections: closeAllConnectionsMock,
|
||||
resolveProxy: resolveProxyMock,
|
||||
setProxy: setProxyMock
|
||||
}
|
||||
}
|
||||
}))
|
||||
// Why here and not in beforeEach: vi.resetModules() above gives the http-client module
|
||||
// a fresh singleton, so the port must be installed on that instance. The electron net
|
||||
// mock alone is inert now that Jira fetches through the port.
|
||||
const { setMainHttpClient } = await import('../network/http-client')
|
||||
setMainHttpClient({
|
||||
fetch: (url, init) => netFetchMock(url, init),
|
||||
proxySession: () => ({ resolveProxy: resolveProxyMock, setProxy: setProxyMock }) as never
|
||||
})
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: options.decryptString ?? ((value) => value.toString('utf-8')),
|
||||
describeProtectionGap: () => null
|
||||
})
|
||||
vi.doMock('os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
})
|
||||
|
||||
// One import call per reset so the split modules share a single graph (and
|
||||
// thus one copy of the request queue / credential caches) per test.
|
||||
const [client, queue, api] = await Promise.all([
|
||||
import('./client'),
|
||||
import('./request-queue'),
|
||||
import('./authenticated-request')
|
||||
])
|
||||
return { ...client, ...queue, ...api }
|
||||
function loadClientModule(options: SafeStorageMockOptions = {}) {
|
||||
return loadJiraClientModule(
|
||||
{ closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock },
|
||||
tempHome,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
+10
-18
@@ -24,10 +24,10 @@ import {
|
||||
authHeader,
|
||||
JiraApiError,
|
||||
jiraRequest,
|
||||
requestWithCredentials,
|
||||
type JiraClientForSite
|
||||
} from './authenticated-request'
|
||||
import { getSiteId, normalizeJiraSiteUrl, siteToViewer, toViewer } from './site-identity'
|
||||
import { verifyJiraCredentials } from './credential-verification'
|
||||
|
||||
export function getClients(selection?: JiraSiteSelection | null): JiraClientForSite[] {
|
||||
const file = getSiteFile()
|
||||
@@ -75,7 +75,8 @@ export function getStatus(): JiraConnectionStatus {
|
||||
activeSiteId: activeSite?.id ?? null,
|
||||
selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null,
|
||||
...(credentialError ? { credentialError } : {}),
|
||||
credentialProtection
|
||||
credentialProtection,
|
||||
supportsScopedTokens: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,24 +102,13 @@ export async function connect(
|
||||
error: email ? 'Password is required.' : 'Personal access token is required.'
|
||||
}
|
||||
}
|
||||
} else if (!email || !apiToken) {
|
||||
return { ok: false, error: 'Email and API token are required.' }
|
||||
} else if (!apiToken) {
|
||||
return { ok: false, error: 'API token is required.' }
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const myselfPath = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself'
|
||||
const viewer = toViewer(
|
||||
(await requestWithCredentials(
|
||||
siteUrl,
|
||||
email,
|
||||
apiToken,
|
||||
myselfPath,
|
||||
undefined,
|
||||
authType
|
||||
)) as Record<string, unknown>,
|
||||
email || siteUrl
|
||||
)
|
||||
const { viewer, apiBaseUrl } = await verifyJiraCredentials(siteUrl, email, apiToken, authType)
|
||||
// PAT sites have no email, so keying on it alone would collide every PAT
|
||||
// connection to the same host into one id (silently overwriting a prior
|
||||
// account + token). Fall back to the verified viewer identity so distinct
|
||||
@@ -130,8 +120,10 @@ export async function connect(
|
||||
email,
|
||||
displayName: viewer.displayName,
|
||||
accountId: viewer.accountId,
|
||||
authType
|
||||
authType,
|
||||
...(apiBaseUrl ? { apiBaseUrl } : {})
|
||||
}
|
||||
clearAttachmentImagesForSite(id)
|
||||
saveToken(id, apiToken)
|
||||
const file = getSiteFile()
|
||||
writeSiteFile({
|
||||
@@ -215,5 +207,5 @@ export function clearToken(siteId: string): void {
|
||||
export function isAuthError(error: unknown): boolean {
|
||||
// Why: Jira returns 403 for project/API permission gaps even when /myself
|
||||
// succeeds, so only 401 means the saved credential itself is invalid.
|
||||
return error instanceof JiraApiError && error.status === 401
|
||||
return error instanceof JiraApiError && error.status === 401 && !error.scopeMismatch
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { JiraApiError, jiraFetch } from './authenticated-request'
|
||||
import { jiraGatewayBaseUrl } from './gateway-base-url'
|
||||
|
||||
export async function resolveJiraGatewayBaseUrl(siteUrl: string): Promise<string> {
|
||||
const tenantInfoUrl = new URL('/_edge/tenant_info', siteUrl).toString()
|
||||
const response = await jiraFetch(tenantInfoUrl, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'Orca' }
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel()
|
||||
throw new JiraApiError(
|
||||
`Could not resolve the Atlassian cloud ID at ${new URL(tenantInfoUrl).host}/_edge/tenant_info (HTTP ${response.status}). Check the Jira Cloud site URL.`,
|
||||
response.status
|
||||
)
|
||||
}
|
||||
const data: unknown = await response.json()
|
||||
return jiraGatewayBaseUrl(
|
||||
data && typeof data === 'object' && 'cloudId' in data ? data.cloudId : undefined
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { JiraAuthType, JiraViewer } from '../../shared/jira-types'
|
||||
import { JiraApiError, requestWithCredentials } from './authenticated-request'
|
||||
import { resolveJiraGatewayBaseUrl } from './cloud-gateway'
|
||||
import { toViewer } from './site-identity'
|
||||
|
||||
function endpointLabel(baseUrl: string, path: string): string {
|
||||
const url = new URL(`${baseUrl}${path}`)
|
||||
return `${url.host}${url.pathname}`
|
||||
}
|
||||
|
||||
function describeFailure(error: unknown, endpoint: string): string {
|
||||
if (error instanceof JiraApiError) {
|
||||
if (error.scopeMismatch) {
|
||||
return error.message
|
||||
}
|
||||
const status = error.status === null ? '' : ` (HTTP ${error.status})`
|
||||
if (error.status === 401) {
|
||||
return `Jira rejected the credential at ${endpoint}${status}. Check the account email, token expiry, and access to this site.`
|
||||
}
|
||||
if (error.status === 403) {
|
||||
return `Jira denied access at ${endpoint}${status}. Check account permissions and API token scopes.`
|
||||
}
|
||||
if (error.status === 200) {
|
||||
return `Jira returned an unexpected account response at ${endpoint}${status}. Check that the site URL includes the Jira installation's base path.`
|
||||
}
|
||||
return `Jira could not verify the credential at ${endpoint}${status}. Check the site URL and Jira availability.`
|
||||
}
|
||||
return `Could not reach ${endpoint}. Check the connection, proxy, and Jira site URL.`
|
||||
}
|
||||
|
||||
async function verifyViewer(
|
||||
baseUrl: string,
|
||||
email: string,
|
||||
apiToken: string,
|
||||
authType: JiraAuthType
|
||||
): Promise<JiraViewer> {
|
||||
const path = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself'
|
||||
let data: unknown
|
||||
try {
|
||||
data = await requestWithCredentials(baseUrl, email, apiToken, path, undefined, authType)
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new JiraApiError('Jira returned a non-JSON account response.', 200)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new JiraApiError('Jira returned an invalid account response.', 200)
|
||||
}
|
||||
const viewer = toViewer(Object.fromEntries(Object.entries(data)), email || baseUrl)
|
||||
if (!viewer.accountId) {
|
||||
throw new JiraApiError('Jira did not return an account identity.', 200)
|
||||
}
|
||||
return viewer
|
||||
}
|
||||
|
||||
export async function verifyJiraCredentials(
|
||||
siteUrl: string,
|
||||
email: string,
|
||||
apiToken: string,
|
||||
authType: JiraAuthType
|
||||
): Promise<{ viewer: JiraViewer; apiBaseUrl?: string }> {
|
||||
const path = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself'
|
||||
let siteFailure: string | undefined
|
||||
if (email || authType === 'server') {
|
||||
try {
|
||||
return { viewer: await verifyViewer(siteUrl, email, apiToken, authType) }
|
||||
} catch (error) {
|
||||
siteFailure = describeFailure(error, endpointLabel(siteUrl, path))
|
||||
if (authType === 'server' || !(error instanceof JiraApiError) || error.status !== 401) {
|
||||
throw new Error(siteFailure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scoped tokens are rejected by the site host; keep browse links there and send REST to the gateway.
|
||||
let apiBaseUrl: string
|
||||
try {
|
||||
apiBaseUrl = await resolveJiraGatewayBaseUrl(siteUrl)
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof JiraApiError
|
||||
? error.message
|
||||
: 'Could not resolve a valid Atlassian cloud ID. Check the Jira Cloud site URL and connection.'
|
||||
throw new Error(siteFailure ? `${siteFailure} ${detail}` : detail)
|
||||
}
|
||||
try {
|
||||
return { viewer: await verifyViewer(apiBaseUrl, email, apiToken, authType), apiBaseUrl }
|
||||
} catch (error) {
|
||||
const detail = describeFailure(error, endpointLabel(apiBaseUrl, path))
|
||||
throw new Error(siteFailure ? `${siteFailure} Gateway check: ${detail}` : detail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const GATEWAY_BASE_URL_RE = /^https:\/\/api\.atlassian\.com\/ex\/jira\/[A-Za-z0-9_-]+$/
|
||||
const CLOUD_ID_RE = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
export function jiraGatewayBaseUrl(cloudId: unknown): string {
|
||||
if (typeof cloudId !== 'string' || !CLOUD_ID_RE.test(cloudId)) {
|
||||
throw new Error('The site did not report a valid Atlassian cloud ID.')
|
||||
}
|
||||
return `https://api.atlassian.com/ex/jira/${cloudId}`
|
||||
}
|
||||
|
||||
export function isJiraGatewayBaseUrl(value: unknown): value is string {
|
||||
return typeof value === 'string' && GATEWAY_BASE_URL_RE.test(value)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export class JiraApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number | null = null,
|
||||
public scopeMismatch = false
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function readJiraError(response: Response): Promise<string> {
|
||||
try {
|
||||
const data: unknown = await response.json()
|
||||
if (data && typeof data === 'object') {
|
||||
const messages: unknown[] = []
|
||||
if ('errorMessages' in data && Array.isArray(data.errorMessages)) {
|
||||
messages.push(...data.errorMessages)
|
||||
}
|
||||
if ('errors' in data && data.errors && typeof data.errors === 'object') {
|
||||
messages.push(...Object.values(data.errors))
|
||||
}
|
||||
if ('message' in data) {
|
||||
messages.push(data.message)
|
||||
}
|
||||
const strings = messages.filter((message): message is string => typeof message === 'string')
|
||||
if (strings.length > 0) {
|
||||
return strings.join('; ')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through for non-JSON responses.
|
||||
}
|
||||
return response.statusText || `Jira request failed (${response.status})`
|
||||
}
|
||||
|
||||
export async function toJiraApiError(response: Response, url: string): Promise<JiraApiError> {
|
||||
const message = await readJiraError(response)
|
||||
const target = new URL(url)
|
||||
const scopeMismatch =
|
||||
target.origin === 'https://api.atlassian.com' &&
|
||||
(response.status === 401 || response.status === 403) &&
|
||||
/scope does not match|insufficient_scope|scope mismatch/i.test(
|
||||
`${message} ${response.headers.get('www-authenticate') ?? ''}`
|
||||
)
|
||||
if (scopeMismatch) {
|
||||
const scope = /\/(myself|user|users)(\/|$)/.test(target.pathname)
|
||||
? 'read:jira-user'
|
||||
: 'read:jira-work for reads or write:jira-work for edits'
|
||||
return new JiraApiError(
|
||||
`Jira rejected this request's API token scopes at ${target.host}${target.pathname} (HTTP ${response.status}). Add ${scope}; board ordering also needs the Jira Software board scopes listed in the connection guide.`,
|
||||
response.status,
|
||||
true
|
||||
)
|
||||
}
|
||||
return new JiraApiError(message, response.status)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
readStoredCredentialToken
|
||||
} from '../integration-credential-file'
|
||||
import type { JiraSite, JiraSiteSelection } from '../../shared/jira-types'
|
||||
import { isJiraGatewayBaseUrl } from './gateway-base-url'
|
||||
|
||||
export type JiraSiteFile = {
|
||||
version: 1
|
||||
@@ -82,6 +83,12 @@ function normalizeSite(input: unknown): JiraSite | null {
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (record.apiBaseUrl !== undefined && !isJiraGatewayBaseUrl(record.apiBaseUrl)) {
|
||||
return null
|
||||
}
|
||||
if (record.authType !== 'server' && !record.email && !isJiraGatewayBaseUrl(record.apiBaseUrl)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
siteUrl: record.siteUrl,
|
||||
@@ -89,7 +96,10 @@ function normalizeSite(input: unknown): JiraSite | null {
|
||||
displayName: record.displayName,
|
||||
accountId: record.accountId,
|
||||
// Sites saved before self-hosted support have no authType; they are Cloud.
|
||||
authType: record.authType === 'server' ? 'server' : 'cloud'
|
||||
authType: record.authType === 'server' ? 'server' : 'cloud',
|
||||
...(record.authType !== 'server' && isJiraGatewayBaseUrl(record.apiBaseUrl)
|
||||
? { apiBaseUrl: record.apiBaseUrl }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ export function normalizeJiraSiteUrl(siteUrl: string): string {
|
||||
const trimmed = siteUrl.trim()
|
||||
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||
const url = new URL(withProtocol)
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) {
|
||||
throw new Error('Enter an HTTP or HTTPS Jira site URL without credentials.')
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/+$/, '')
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function JiraConnectCredentialGuide({
|
||||
isServer,
|
||||
isServerBasic,
|
||||
supportsScopedTokens
|
||||
}: {
|
||||
isServer: boolean
|
||||
isServerBasic: boolean
|
||||
supportsScopedTokens: boolean
|
||||
}): React.JSX.Element {
|
||||
if (isServer) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isServerBasic
|
||||
? translate(
|
||||
'auto.components.jira.connect.dialog.d8737db691',
|
||||
'Use your Jira Server or Data Center account username and password.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.jira.connect.dialog.ccfb086d3e',
|
||||
'Create a personal access token in your Jira profile under Personal Access Tokens.'
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
<p>
|
||||
{translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
onClick={() =>
|
||||
window.api.shell.openUrl('https://id.atlassian.com/manage-profile/security/api-tokens')
|
||||
}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.jira.connect.dialog.fdd26d81cc',
|
||||
'Atlassian account settings'
|
||||
)}
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
{supportsScopedTokens
|
||||
? translate(
|
||||
'jira.connect.scopedTokenGuide',
|
||||
'Scoped tokens need read:jira-work, write:jira-work, and read:jira-user. Leave email empty to use Bearer authentication.'
|
||||
)
|
||||
: translate(
|
||||
'jira.connect.oldHostGuide',
|
||||
'This remote runtime requires an email and an unscoped API token. Update the remote runtime to use scoped tokens.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,9 +15,13 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context'
|
||||
import {
|
||||
getProviderRuntimeContextKey,
|
||||
isEnvironmentRuntimeContextKey
|
||||
} from '@/lib/provider-runtime-context'
|
||||
import { preventOutsideDismissWhenDirty } from '@/lib/outside-dismiss-guard'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { JiraConnectCredentialGuide } from './jira-connect-credential-guide'
|
||||
|
||||
type JiraConnectDialogProps = {
|
||||
open: boolean
|
||||
@@ -45,6 +49,13 @@ export function JiraConnectDialog({
|
||||
}: JiraConnectDialogProps): React.JSX.Element {
|
||||
const connectJira = useAppStore((s) => s.connectJira)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const hostSupportsScopedTokens = useAppStore((s) => s.jiraStatus?.supportsScopedTokens === true)
|
||||
const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey)
|
||||
const providerRuntimeContextKey = getProviderRuntimeContextKey(settings)
|
||||
const remoteRuntime = isEnvironmentRuntimeContextKey(providerRuntimeContextKey)
|
||||
const supportsScopedTokens =
|
||||
!remoteRuntime ||
|
||||
(jiraStatusContextKey === providerRuntimeContextKey && hostSupportsScopedTokens)
|
||||
const mountedRef = useMountedRef()
|
||||
const siteUrlId = useId()
|
||||
const emailId = useId()
|
||||
@@ -81,12 +92,13 @@ export function JiraConnectDialog({
|
||||
// uses no identity, so the email field is hidden and left empty.
|
||||
const isServerBasic = isServer && serverAuthMethod === 'basic'
|
||||
const needsIdentity = !isServer || isServerBasic
|
||||
const requiresIdentity = isServerBasic || (!isServer && !supportsScopedTokens)
|
||||
const canSubmit =
|
||||
Boolean(siteUrl.trim()) &&
|
||||
(!needsIdentity || Boolean(email.trim())) &&
|
||||
(!requiresIdentity || Boolean(email.trim())) &&
|
||||
Boolean(apiToken.trim()) &&
|
||||
connectState !== 'connecting'
|
||||
const credentialStorageCopy = hasRemoteProviderRuntime(settings)
|
||||
const credentialStorageCopy = remoteRuntime
|
||||
? 'Your token is sent to the selected remote runtime and stored there with runtime-supported encryption.'
|
||||
: 'Your token is stored locally and encrypted when local runtime storage supports it.'
|
||||
|
||||
@@ -124,7 +136,7 @@ export function JiraConnectDialog({
|
||||
const trimmedToken = apiToken.trim()
|
||||
if (
|
||||
!trimmedSite ||
|
||||
(needsIdentity && !trimmedEmail) ||
|
||||
(requiresIdentity && !trimmedEmail) ||
|
||||
!trimmedToken ||
|
||||
connectState === 'connecting'
|
||||
) {
|
||||
@@ -179,10 +191,15 @@ export function JiraConnectDialog({
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{!isServer
|
||||
? translate(
|
||||
'auto.components.jira.connect.dialog.d785c42b8b',
|
||||
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
|
||||
)
|
||||
? supportsScopedTokens
|
||||
? translate(
|
||||
'jira.connect.scopedDescription',
|
||||
'Use a Jira Cloud site URL and API token to browse issues. Scoped tokens can omit the email.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.jira.connect.dialog.d785c42b8b',
|
||||
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
|
||||
)
|
||||
: isServerBasic
|
||||
? translate(
|
||||
'auto.components.jira.connect.dialog.1d947a07ab',
|
||||
@@ -295,10 +312,15 @@ export function JiraConnectDialog({
|
||||
<Label htmlFor={emailId} className="text-xs">
|
||||
{isServerBasic
|
||||
? translate('auto.components.jira.connect.dialog.8d1223fa5c', 'Username')
|
||||
: translate(
|
||||
'auto.components.jira.connect.dialog.2849ddb295',
|
||||
'Atlassian email'
|
||||
)}
|
||||
: supportsScopedTokens
|
||||
? translate(
|
||||
'jira.connect.optionalEmail',
|
||||
'Atlassian email (optional for scoped tokens)'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.jira.connect.dialog.2849ddb295',
|
||||
'Atlassian email'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id={emailId}
|
||||
@@ -356,49 +378,19 @@ export function JiraConnectDialog({
|
||||
clearErrorOnEdit()
|
||||
}}
|
||||
disabled={connectState === 'connecting'}
|
||||
aria-invalid={connectState === 'error'}
|
||||
aria-describedby={connectState === 'error' ? errorId : undefined}
|
||||
/>
|
||||
</div>
|
||||
{connectState === 'error' && connectError ? (
|
||||
<p id={errorId} className="text-xs text-destructive">
|
||||
<p id={errorId} role="alert" className="text-xs text-destructive">
|
||||
{connectError}
|
||||
</p>
|
||||
) : null}
|
||||
{isServerBasic ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.jira.connect.dialog.d8737db691',
|
||||
'Use your Jira Server or Data Center account username and password.'
|
||||
)}
|
||||
</p>
|
||||
) : isServer ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.jira.connect.dialog.ccfb086d3e',
|
||||
'Create a personal access token in your Jira profile under Personal Access Tokens.'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
onClick={() =>
|
||||
window.api.shell.openUrl(
|
||||
'https://id.atlassian.com/manage-profile/security/api-tokens'
|
||||
)
|
||||
}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.jira.connect.dialog.fdd26d81cc',
|
||||
'Atlassian account settings'
|
||||
)}
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
<JiraConnectCredentialGuide
|
||||
isServer={isServer}
|
||||
isServerBasic={isServerBasic}
|
||||
supportsScopedTokens={supportsScopedTokens}
|
||||
/>
|
||||
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
|
||||
<Lock className="size-3 shrink-0" />
|
||||
{credentialStorageCopy}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { fireEvent, render, screen, cleanup, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JiraConnectionStatus } from '../../../shared/jira-types'
|
||||
import { JiraConnectDialog } from './jira-connect-dialog'
|
||||
import { getEnvironmentRuntimeContextKey } from '@/lib/provider-runtime-context'
|
||||
|
||||
type FixtureState = {
|
||||
settings: { activeRuntimeEnvironmentId: string | null }
|
||||
jiraStatus: JiraConnectionStatus
|
||||
jiraStatusContextKey: string | null
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: FixtureState = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
jiraStatus: { connected: false, viewer: null },
|
||||
jiraStatusContextKey: null
|
||||
}
|
||||
return {
|
||||
connectJira: vi.fn(),
|
||||
state
|
||||
}
|
||||
})
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (
|
||||
selector: (state: typeof mocks.state & { connectJira: typeof mocks.connectJira }) => unknown
|
||||
) => selector({ ...mocks.state, connectJira: mocks.connectJira })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.connectJira.mockReset().mockResolvedValue({ ok: true })
|
||||
mocks.state.settings.activeRuntimeEnvironmentId = null
|
||||
mocks.state.jiraStatus = { connected: false, viewer: null }
|
||||
mocks.state.jiraStatusContextKey = null
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
function fillSiteAndToken(): void {
|
||||
fireEvent.change(screen.getByLabelText('Jira Cloud site URL'), {
|
||||
target: { value: 'https://example.atlassian.net' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('API token'), { target: { value: 'scoped-token' } })
|
||||
}
|
||||
|
||||
describe('Jira scoped-token connection UI', () => {
|
||||
it('requires email until the selected runtime confirms its own scoped-token capability', () => {
|
||||
mocks.state.settings.activeRuntimeEnvironmentId = 'new-runtime'
|
||||
mocks.state.jiraStatus.supportsScopedTokens = true
|
||||
mocks.state.jiraStatusContextKey = getEnvironmentRuntimeContextKey('old-runtime')
|
||||
const view = render(<JiraConnectDialog open onOpenChange={vi.fn()} />)
|
||||
fillSiteAndToken()
|
||||
expect(screen.getByLabelText('Atlassian email')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveProperty('disabled', true)
|
||||
mocks.state.jiraStatusContextKey = getEnvironmentRuntimeContextKey('new-runtime')
|
||||
view.rerender(<JiraConnectDialog open onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByLabelText('Atlassian email (optional for scoped tokens)')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveProperty('disabled', false)
|
||||
})
|
||||
|
||||
it('submits an email-free Cloud token to an upgraded host', async () => {
|
||||
mocks.state.settings.activeRuntimeEnvironmentId = 'remote-1'
|
||||
mocks.state.jiraStatus.supportsScopedTokens = true
|
||||
mocks.state.jiraStatusContextKey = getEnvironmentRuntimeContextKey('remote-1')
|
||||
render(<JiraConnectDialog open onOpenChange={vi.fn()} />)
|
||||
fillSiteAndToken()
|
||||
expect(screen.getByLabelText('Atlassian email (optional for scoped tokens)')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
await waitFor(() =>
|
||||
expect(mocks.connectJira).toHaveBeenCalledWith({
|
||||
siteUrl: 'https://example.atlassian.net',
|
||||
email: '',
|
||||
apiToken: 'scoped-token',
|
||||
authType: 'cloud'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the original email requirement when the remote host omits the capability', () => {
|
||||
mocks.state.settings.activeRuntimeEnvironmentId = 'old-remote'
|
||||
render(<JiraConnectDialog open onOpenChange={vi.fn()} />)
|
||||
fillSiteAndToken()
|
||||
expect(screen.getByLabelText('Atlassian email')).toBeTruthy()
|
||||
const submit = screen.getByRole('button', { name: 'Connect' })
|
||||
expect(submit).toHaveProperty('disabled', true)
|
||||
expect(screen.getByText(/Update the remote runtime/)).toBeTruthy()
|
||||
fireEvent.click(submit)
|
||||
expect(mocks.connectJira).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows endpoint diagnostics without marking the token as the failing field', async () => {
|
||||
mocks.connectJira.mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'Could not resolve the Atlassian cloud ID (HTTP 404). Check the Jira Cloud site URL.'
|
||||
})
|
||||
render(<JiraConnectDialog open onOpenChange={vi.fn()} />)
|
||||
fillSiteAndToken()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('cloud ID'))
|
||||
expect(screen.getByLabelText('API token').getAttribute('aria-invalid')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -19966,5 +19966,13 @@
|
||||
"preparingLabel": "Preparing update",
|
||||
"preparingAriaLabel": "Preparing update. Click to expand."
|
||||
}
|
||||
},
|
||||
"jira": {
|
||||
"connect": {
|
||||
"scopedTokenGuide": "Scoped tokens need read:jira-work, write:jira-work, and read:jira-user. Leave email empty to use Bearer authentication.",
|
||||
"oldHostGuide": "This remote runtime requires an email and an unscoped API token. Update the remote runtime to use scoped tokens.",
|
||||
"scopedDescription": "Use a Jira Cloud site URL and API token to browse issues. Scoped tokens can omit the email.",
|
||||
"optionalEmail": "Atlassian email (optional for scoped tokens)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,32 @@ import { describe, expect, it } from 'vitest'
|
||||
import { parseJiraConnectionStatus } from './runtime-jira-connection-status'
|
||||
|
||||
describe('parseJiraConnectionStatus', () => {
|
||||
it('accepts an upgraded host without changing the Cloud/Server values older readers know', () => {
|
||||
const site = {
|
||||
id: 'site-scoped',
|
||||
siteUrl: 'https://example.atlassian.net',
|
||||
email: '',
|
||||
displayName: 'Ada',
|
||||
accountId: 'account-alpha',
|
||||
authType: 'cloud',
|
||||
apiBaseUrl: 'https://api.atlassian.com/ex/jira/cloud-abc'
|
||||
}
|
||||
const status = parseJiraConnectionStatus({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
sites: [site],
|
||||
supportsScopedTokens: true
|
||||
})
|
||||
expect(status.sites).toEqual([site])
|
||||
expect(status.supportsScopedTokens).toBe(true)
|
||||
expect(
|
||||
parseJiraConnectionStatus({ connected: true, viewer: null }).supportsScopedTokens
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
parseJiraConnectionStatus({ connected: true, supportsScopedTokens: 'yes' })
|
||||
.supportsScopedTokens
|
||||
).toBeUndefined()
|
||||
})
|
||||
it.each([
|
||||
['undefined', undefined],
|
||||
['null', null],
|
||||
|
||||
@@ -62,7 +62,8 @@ function parseJiraSite(value: unknown): JiraSite | null {
|
||||
email: typeof value.email === 'string' ? value.email : '',
|
||||
displayName: value.displayName,
|
||||
accountId: value.accountId,
|
||||
authType: isJiraAuthType(value.authType) ? value.authType : undefined
|
||||
authType: isJiraAuthType(value.authType) ? value.authType : undefined,
|
||||
apiBaseUrl: typeof value.apiBaseUrl === 'string' ? value.apiBaseUrl : undefined
|
||||
}
|
||||
return site
|
||||
}
|
||||
@@ -95,7 +96,9 @@ export function parseJiraConnectionStatus(value: unknown): JiraConnectionStatus
|
||||
credentialProtection:
|
||||
value.credentialProtection === null || isSecretAtRestProtection(value.credentialProtection)
|
||||
? value.credentialProtection
|
||||
: undefined
|
||||
: undefined,
|
||||
supportsScopedTokens:
|
||||
typeof value.supportsScopedTokens === 'boolean' ? value.supportsScopedTokens : undefined
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ function hasJiraStatusChanged(
|
||||
previous.connected !== next.connected ||
|
||||
previous.credentialError !== next.credentialError ||
|
||||
previous.credentialProtection !== next.credentialProtection ||
|
||||
previous.supportsScopedTokens !== next.supportsScopedTokens ||
|
||||
previous.activeSiteId !== next.activeSiteId ||
|
||||
!shallow(previous.viewer, next.viewer) ||
|
||||
getSelectedJiraSiteId(previous) !== getSelectedJiraSiteId(next) ||
|
||||
@@ -85,12 +86,10 @@ export function createJiraConnectionActions(
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (get().jiraStatus.connected) {
|
||||
if (get().jiraStatusContextKey !== contextKey || get().jiraStatus.connected) {
|
||||
set((state) => jiraStatusUpdate(state, contextKey, { connected: false, viewer: null }))
|
||||
} else if (!get().jiraStatusChecked) {
|
||||
set({ jiraStatusChecked: true, jiraStatusContextKey: contextKey })
|
||||
} else if (get().jiraStatusContextKey !== contextKey) {
|
||||
set({ jiraStatusContextKey: contextKey })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -32,7 +32,9 @@ function status(accountId: string): JiraConnectionStatus {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => jiraStatus.mockReset())
|
||||
beforeEach(() => {
|
||||
jiraStatus.mockReset()
|
||||
})
|
||||
|
||||
it('replaces status when the provider context changes even if its visible values are identical', async () => {
|
||||
const store = createTestStore()
|
||||
@@ -79,3 +81,20 @@ it('preserves the status reference for an unchanged identity on the same host',
|
||||
await store.getState().checkJiraConnection()
|
||||
expect(store.getState().jiraStatus).toBe(initial)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'clears the old disconnected host capability when the new status read fails (checked: %s)',
|
||||
async (jiraStatusChecked) => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
jiraStatus: { connected: false, viewer: null, supportsScopedTokens: true },
|
||||
jiraStatusChecked,
|
||||
jiraStatusContextKey: 'runtime:old-host#0'
|
||||
})
|
||||
jiraStatus.mockRejectedValueOnce(new Error('Host unavailable'))
|
||||
await store.getState().checkJiraConnection()
|
||||
expect(store.getState().jiraStatus).toEqual({ connected: false, viewer: null })
|
||||
expect(store.getState().jiraStatusChecked).toBe(true)
|
||||
expect(store.getState().jiraStatusContextKey).toBe('local#0')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,6 +11,8 @@ export type JiraSite = {
|
||||
displayName: string
|
||||
accountId: string
|
||||
authType?: JiraAuthType
|
||||
// REST gateway for scoped Cloud tokens; siteUrl remains the browse-link origin.
|
||||
apiBaseUrl?: string
|
||||
}
|
||||
|
||||
export type JiraViewer = {
|
||||
@@ -34,6 +36,8 @@ export type JiraConnectionStatus = {
|
||||
// 'plaintext' when any stored token is unsealed, so Settings can warn. Optional:
|
||||
// an older remote host omits it, and absent must read as "unknown", not "sealed".
|
||||
credentialProtection?: SecretAtRestProtection | null
|
||||
// Absent on older remote hosts, which require a Cloud email and unscoped token.
|
||||
supportsScopedTokens?: boolean
|
||||
}
|
||||
|
||||
export type JiraProject = {
|
||||
@@ -141,8 +145,7 @@ export type JiraIssueFilter = 'assigned' | 'reported' | 'all' | 'done'
|
||||
|
||||
export type JiraConnectArgs = {
|
||||
siteUrl: string
|
||||
// Ignored for 'server' auth: self-hosted PATs authenticate via Bearer
|
||||
// header alone, so the email field may be empty.
|
||||
// Empty for Bearer authentication: self-hosted PAT or scoped Cloud token.
|
||||
email: string
|
||||
apiToken: string
|
||||
authType?: JiraAuthType
|
||||
|
||||
Reference in New Issue
Block a user