Handle integration credential decrypt failures (#4683)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
31f0f254eb
commit
ceae167427
|
|
@ -0,0 +1,80 @@
|
|||
import { statSync } from 'fs'
|
||||
import { safeStorage } from 'electron'
|
||||
import {
|
||||
credentialDecryptionMessage,
|
||||
type IntegrationCredentialService
|
||||
} from '../shared/integration-credential-errors'
|
||||
|
||||
// Why: connection status treats a token file as a saved credential; empty
|
||||
// files read as "missing", so counting them would split-brain getStatus.
|
||||
export function credentialFileHasContent(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).size > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export class CredentialDecryptionError extends Error {
|
||||
constructor(service: IntegrationCredentialService) {
|
||||
super(credentialDecryptionMessage(service))
|
||||
this.name = 'CredentialDecryptionError'
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the stored token, null when the file is empty, and throws
|
||||
// CredentialDecryptionError when the file holds ciphertext we cannot decrypt
|
||||
// (e.g. the user denied the OS keychain prompt after an app re-sign).
|
||||
export function readStoredCredentialToken(
|
||||
service: IntegrationCredentialService,
|
||||
raw: Buffer
|
||||
): string | null {
|
||||
if (raw.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
return usableToken(safeStorage.decryptString(raw))
|
||||
} catch {
|
||||
return readPlaintextLegacyCredential(service, raw)
|
||||
}
|
||||
}
|
||||
|
||||
return readPlaintextLegacyCredential(service, raw)
|
||||
}
|
||||
|
||||
function readPlaintextLegacyCredential(
|
||||
service: IntegrationCredentialService,
|
||||
raw: Buffer
|
||||
): string | null {
|
||||
const plaintext = decodeUtf8(raw)
|
||||
// Why: legacy plaintext tokens are printable UTF-8; safeStorage ciphertext
|
||||
// such as macOS v10 blobs must not be decoded into auth-header junk.
|
||||
if (plaintext === null || hasControlCharacter(plaintext)) {
|
||||
throw new CredentialDecryptionError(service)
|
||||
}
|
||||
return usableToken(plaintext)
|
||||
}
|
||||
|
||||
function usableToken(token: string): string | null {
|
||||
return token.length > 0 ? token : null
|
||||
}
|
||||
|
||||
function decodeUtf8(raw: Buffer): string | null {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code < 0x20 || code === 0x7f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import type * as Os from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const OLD_FETCH = globalThis.fetch
|
||||
|
||||
type SafeStorageMockOptions = {
|
||||
encryptionAvailable?: boolean
|
||||
decryptString?: (value: Buffer) => string
|
||||
}
|
||||
|
||||
let tempHome = ''
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
function mkdtempLike(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), prefix))
|
||||
}
|
||||
|
||||
function tokenPathForSite(siteId: string): string {
|
||||
return join(tempHome, '.orca', 'jira-tokens', `${Buffer.from(siteId).toString('base64url')}.enc`)
|
||||
}
|
||||
|
||||
function writeJiraFiles(siteId: string, token: string | Buffer): void {
|
||||
const orcaDir = join(tempHome, '.orca')
|
||||
mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(orcaDir, 'jira-sites.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
activeSiteId: siteId,
|
||||
selectedSiteId: siteId,
|
||||
sites: [
|
||||
{
|
||||
id: siteId,
|
||||
siteUrl: 'https://example.atlassian.net',
|
||||
email: 'ada@example.com',
|
||||
displayName: 'Ada',
|
||||
accountId: 'account-alpha'
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
writeFileSync(tokenPathForSite(siteId), token)
|
||||
}
|
||||
|
||||
async function loadClientModule(options: SafeStorageMockOptions = {}) {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8'))
|
||||
}
|
||||
}))
|
||||
vi.doMock('os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
})
|
||||
|
||||
return import('./client')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = mkdtempLike('orca-jira-client-')
|
||||
fetchMock = vi.fn(async () => {
|
||||
throw new Error('fetch should not be called')
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = OLD_FETCH
|
||||
})
|
||||
|
||||
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(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
accountId: 'account-alpha',
|
||||
displayName: 'Ada',
|
||||
emailAddress: 'ada@example.com'
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
const jira = await loadClientModule({
|
||||
encryptionAvailable: true,
|
||||
decryptString: () => {
|
||||
throw new Error('not encrypted')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
viewer: { displayName: 'Ada' }
|
||||
})
|
||||
|
||||
const headers = fetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(headers.get('Authorization')).toBe(
|
||||
`Basic ${Buffer.from('ada@example.com:token-alpha').toString('base64')}`
|
||||
)
|
||||
})
|
||||
|
||||
it('does not pass encrypted safeStorage bytes to Jira when encryption is unavailable', async () => {
|
||||
const siteId = 'site-alpha'
|
||||
const tokenPath = tokenPathForSite(siteId)
|
||||
writeJiraFiles(siteId, Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]))
|
||||
const jira = await loadClientModule({ encryptionAvailable: false })
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.'
|
||||
})
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(existsSync(tokenPath)).toBe(true)
|
||||
expect(jira.getStatus()).toMatchObject({
|
||||
connected: true,
|
||||
credentialError:
|
||||
'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.',
|
||||
sites: [{ id: siteId }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not clear the Jira token when safeStorage decryption fails', async () => {
|
||||
const siteId = 'site-alpha'
|
||||
const tokenPath = tokenPathForSite(siteId)
|
||||
writeJiraFiles(siteId, Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]))
|
||||
const jira = await loadClientModule({
|
||||
encryptionAvailable: true,
|
||||
decryptString: () => {
|
||||
throw new Error('userCanceledErr')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.'
|
||||
})
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(existsSync(tokenPath)).toBe(true)
|
||||
expect(jira.getStatus()).toMatchObject({
|
||||
connected: true,
|
||||
credentialError:
|
||||
'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.',
|
||||
sites: [{ id: siteId }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not clear plaintext fallback credentials on Jira auth failure after decrypt failure', async () => {
|
||||
const siteId = 'site-alpha'
|
||||
const tokenPath = tokenPathForSite(siteId)
|
||||
writeJiraFiles(siteId, 'token-revoked')
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ errorMessages: ['Jira authentication failed'] }), {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
const jira = await loadClientModule({
|
||||
encryptionAvailable: true,
|
||||
decryptString: () => {
|
||||
throw new Error('userCanceledErr')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Jira authentication failed'
|
||||
})
|
||||
|
||||
expect(existsSync(tokenPath)).toBe(true)
|
||||
expect(jira.getStatus()).toMatchObject({
|
||||
connected: true,
|
||||
sites: [{ id: siteId }]
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the recorded credential error after Keychain access is approved', async () => {
|
||||
const siteId = 'site-alpha'
|
||||
let keychainApproved = false
|
||||
writeJiraFiles(siteId, Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]))
|
||||
fetchMock.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({
|
||||
encryptionAvailable: true,
|
||||
decryptString: () => {
|
||||
if (!keychainApproved) {
|
||||
throw new Error('userCanceledErr')
|
||||
}
|
||||
return 'token-alpha'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toMatchObject({ ok: false })
|
||||
expect(jira.getStatus().credentialError).toContain('Could not decrypt')
|
||||
|
||||
keychainApproved = true
|
||||
await expect(jira.testConnection(siteId)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
viewer: { displayName: 'Ada' }
|
||||
})
|
||||
expect(jira.getStatus().credentialError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats empty Jira token files as missing credentials', async () => {
|
||||
const siteId = 'site-alpha'
|
||||
writeJiraFiles(siteId, Buffer.alloc(0))
|
||||
const jira = await loadClientModule({ encryptionAvailable: false })
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Not connected to Jira.'
|
||||
})
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(jira.getStatus()).toMatchObject({ connected: false })
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,11 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
|
|||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { safeStorage } from 'electron'
|
||||
import {
|
||||
CredentialDecryptionError,
|
||||
credentialFileHasContent,
|
||||
readStoredCredentialToken
|
||||
} from '../integration-credential-file'
|
||||
import type {
|
||||
JiraConnectArgs,
|
||||
JiraConnectionStatus,
|
||||
|
|
@ -63,6 +68,9 @@ export class JiraApiError extends Error {
|
|||
let cachedSiteFile: JiraSiteFile | null = null
|
||||
let siteFileLoaded = false
|
||||
const cachedTokens = new Map<string, string>()
|
||||
// Why: decrypt failures are recorded per site so getStatus can explain
|
||||
// failing reads without re-touching the keychain on every status poll.
|
||||
const credentialErrors = new Map<string, string>()
|
||||
|
||||
function getOrcaDir(): string {
|
||||
return join(homedir(), '.orca')
|
||||
|
|
@ -104,7 +112,7 @@ function emptySiteFile(): JiraSiteFile {
|
|||
}
|
||||
|
||||
function hasStoredToken(siteId: string): boolean {
|
||||
return cachedTokens.has(siteId) || existsSync(getTokenPath(siteId))
|
||||
return cachedTokens.has(siteId) || credentialFileHasContent(getTokenPath(siteId))
|
||||
}
|
||||
|
||||
function normalizeSite(input: unknown): JiraSite | null {
|
||||
|
|
@ -206,7 +214,7 @@ function writeEncryptedToken(path: string, apiToken: string): void {
|
|||
|
||||
function readToken(siteId: string): string | null {
|
||||
const cached = cachedTokens.get(siteId)
|
||||
if (cached) {
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const path = getTokenPath(siteId)
|
||||
|
|
@ -215,12 +223,17 @@ function readToken(siteId: string): string | null {
|
|||
}
|
||||
try {
|
||||
const raw = readFileSync(path)
|
||||
const token = safeStorage.isEncryptionAvailable()
|
||||
? safeStorage.decryptString(raw)
|
||||
: raw.toString('utf-8')
|
||||
cachedTokens.set(siteId, token)
|
||||
const token = readStoredCredentialToken('Jira', raw)
|
||||
if (token) {
|
||||
cachedTokens.set(siteId, token)
|
||||
}
|
||||
credentialErrors.delete(siteId)
|
||||
return token
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialDecryptionError) {
|
||||
credentialErrors.set(siteId, error.message)
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -230,10 +243,12 @@ function saveToken(siteId: string, apiToken: string): void {
|
|||
ensureTokenDir()
|
||||
writeEncryptedToken(getTokenPath(siteId), apiToken)
|
||||
cachedTokens.set(siteId, apiToken)
|
||||
credentialErrors.delete(siteId)
|
||||
}
|
||||
|
||||
function deleteToken(siteId: string): void {
|
||||
cachedTokens.delete(siteId)
|
||||
credentialErrors.delete(siteId)
|
||||
try {
|
||||
unlinkSync(getTokenPath(siteId))
|
||||
} catch {
|
||||
|
|
@ -373,12 +388,16 @@ export function getStatus(): JiraConnectionStatus {
|
|||
const file = getSiteFile()
|
||||
const sites = file.sites.filter((site) => hasStoredToken(site.id))
|
||||
const activeSite = sites.find((site) => site.id === file.activeSiteId) ?? sites[0] ?? null
|
||||
const credentialError = sites
|
||||
.map((site) => credentialErrors.get(site.id))
|
||||
.find((message) => message !== undefined)
|
||||
return {
|
||||
connected: sites.length > 0,
|
||||
viewer: siteToViewer(activeSite),
|
||||
sites,
|
||||
activeSiteId: activeSite?.id ?? null,
|
||||
selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null
|
||||
selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null,
|
||||
...(credentialError ? { credentialError } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -461,7 +480,12 @@ export function selectSite(siteId: JiraSiteSelection): JiraConnectionStatus {
|
|||
export async function testConnection(
|
||||
siteId?: string
|
||||
): Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> {
|
||||
const client = getClients(siteId)[0]
|
||||
let client: JiraClientForSite | undefined
|
||||
try {
|
||||
client = getClients(siteId)[0]
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' }
|
||||
}
|
||||
if (!client) {
|
||||
return { ok: false, error: 'Not connected to Jira.' }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JiraClientForSite } from './client'
|
||||
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
|
||||
|
||||
const { clearTokenMock, getClientsMock, jiraRequestMock } = vi.hoisted(() => ({
|
||||
const { clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } = vi.hoisted(() => ({
|
||||
clearTokenMock: vi.fn(),
|
||||
getClientsMock: vi.fn(),
|
||||
isAuthErrorMock: vi.fn(),
|
||||
jiraRequestMock: vi.fn()
|
||||
}))
|
||||
|
||||
|
|
@ -12,7 +14,7 @@ vi.mock('./client', () => ({
|
|||
release: vi.fn(),
|
||||
clearToken: (...args: unknown[]) => clearTokenMock(...args),
|
||||
getClients: (...args: unknown[]) => getClientsMock(...args),
|
||||
isAuthError: vi.fn().mockReturnValue(false),
|
||||
isAuthError: (...args: unknown[]) => isAuthErrorMock(...args),
|
||||
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args)
|
||||
}))
|
||||
|
||||
|
|
@ -32,9 +34,32 @@ function makeEntry(): JiraClientForSite {
|
|||
describe('Jira issue operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isAuthErrorMock.mockReturnValue(false)
|
||||
getClientsMock.mockReturnValue([makeEntry()])
|
||||
})
|
||||
|
||||
it('surfaces Jira credential decrypt errors on active issue, metadata, and mutation paths', async () => {
|
||||
const error = new Error(credentialDecryptionMessage('Jira'))
|
||||
getClientsMock.mockImplementation(() => {
|
||||
throw error
|
||||
})
|
||||
const { createIssue, getIssue, listIssueTypes, listProjects, searchIssues } =
|
||||
await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow(error.message)
|
||||
await expect(getIssue('ALP-1', 'site-1')).rejects.toThrow(error.message)
|
||||
await expect(listProjects('site-1')).rejects.toThrow(error.message)
|
||||
await expect(listIssueTypes('10000', 'site-1')).rejects.toThrow(error.message)
|
||||
await expect(
|
||||
createIssue({
|
||||
siteId: 'site-1',
|
||||
projectId: '10000',
|
||||
issueTypeId: '10001',
|
||||
title: 'Fix auth'
|
||||
})
|
||||
).rejects.toThrow(error.message)
|
||||
})
|
||||
|
||||
it('paginates Jira project search results before sorting them', async () => {
|
||||
jiraRequestMock
|
||||
.mockResolvedValueOnce({
|
||||
|
|
|
|||
|
|
@ -16,16 +16,25 @@ let tempHome = ''
|
|||
let fixtures = new Map<string, ViewerFixture>()
|
||||
let linearClientMock: ReturnType<typeof vi.fn>
|
||||
|
||||
type SafeStorageMockOptions = {
|
||||
encryptionAvailable?: boolean
|
||||
decryptString?: (value: Buffer) => string
|
||||
}
|
||||
|
||||
function writeLegacyLinearFiles(token: string, viewer: Record<string, unknown>): void {
|
||||
writeLegacyLinearToken(token, viewer)
|
||||
}
|
||||
|
||||
function writeLegacyLinearToken(token: string | Buffer, viewer: Record<string, unknown>): void {
|
||||
const orcaDir = join(tempHome, '.orca')
|
||||
mkdirSync(orcaDir, { recursive: true })
|
||||
writeFileSync(join(orcaDir, 'linear-token.enc'), token, { encoding: 'utf-8' })
|
||||
writeFileSync(join(orcaDir, 'linear-token.enc'), token)
|
||||
writeFileSync(join(orcaDir, 'linear-viewer.json'), JSON.stringify(viewer), {
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
}
|
||||
|
||||
async function loadClientModule() {
|
||||
async function loadClientModule(options: SafeStorageMockOptions = {}) {
|
||||
vi.resetModules()
|
||||
linearClientMock = vi.fn(function LinearClient(
|
||||
this: { viewer: Promise<unknown> },
|
||||
|
|
@ -47,17 +56,18 @@ async function loadClientModule() {
|
|||
})
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString('utf-8')
|
||||
decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8'))
|
||||
}
|
||||
}))
|
||||
vi.doMock('os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
})
|
||||
class AuthenticationLinearError extends Error {}
|
||||
vi.doMock('@linear/sdk', () => ({
|
||||
AuthenticationLinearError: class AuthenticationLinearError extends Error {},
|
||||
AuthenticationLinearError,
|
||||
LinearClient: linearClientMock
|
||||
}))
|
||||
|
||||
|
|
@ -167,4 +177,129 @@ describe('Linear client workspace storage', () => {
|
|||
'org-alpha'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves plaintext legacy token fallback when safeStorage cannot decrypt it', async () => {
|
||||
writeLegacyLinearFiles('token-alpha', {
|
||||
displayName: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
organizationName: 'Alpha'
|
||||
})
|
||||
const linear = await loadClientModule({
|
||||
encryptionAvailable: true,
|
||||
decryptString: () => {
|
||||
throw new Error('not encrypted')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(linear.testConnection('legacy')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
workspace: { id: 'org-alpha', organizationName: 'Alpha' }
|
||||
})
|
||||
|
||||
expect(linearClientMock).toHaveBeenCalledWith({ apiKey: 'token-alpha' })
|
||||
})
|
||||
|
||||
it('does not pass encrypted safeStorage bytes to the Linear SDK when encryption is unavailable', async () => {
|
||||
const tokenPath = join(tempHome, '.orca', 'linear-token.enc')
|
||||
writeLegacyLinearToken(Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]), {
|
||||
displayName: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
organizationName: 'Alpha'
|
||||
})
|
||||
const linear = await loadClientModule({ encryptionAvailable: false })
|
||||
|
||||
await expect(linear.testConnection('legacy')).resolves.toEqual({
|
||||
ok: false,
|
||||
error:
|
||||
'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.'
|
||||
})
|
||||
|
||||
expect(linearClientMock).not.toHaveBeenCalled()
|
||||
expect(existsSync(tokenPath)).toBe(true)
|
||||
expect(linear.getStatus()).toMatchObject({
|
||||
connected: true,
|
||||
credentialError:
|
||||
'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.',
|
||||
workspaces: [{ id: 'legacy' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not clear the Linear token when safeStorage decryption fails', async () => {
|
||||
const tokenPath = join(tempHome, '.orca', 'linear-token.enc')
|
||||
writeLegacyLinearToken(Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]), {
|
||||
displayName: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
organizationName: 'Alpha'
|
||||
})
|
||||
const linear = await loadClientModule({
|
||||
encryptionAvailable: true,
|
||||
decryptString: () => {
|
||||
throw new Error('userCanceledErr')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(linear.testConnection('legacy')).resolves.toEqual({
|
||||
ok: false,
|
||||
error:
|
||||
'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.'
|
||||
})
|
||||
|
||||
expect(linearClientMock).not.toHaveBeenCalled()
|
||||
expect(existsSync(tokenPath)).toBe(true)
|
||||
expect(linear.getStatus()).toMatchObject({
|
||||
connected: true,
|
||||
credentialError:
|
||||
'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.',
|
||||
workspaces: [{ id: 'legacy' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the recorded credential error after Keychain access is approved', async () => {
|
||||
let keychainApproved = false
|
||||
writeLegacyLinearToken(Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]), {
|
||||
displayName: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
organizationName: 'Alpha'
|
||||
})
|
||||
const linear = await loadClientModule({
|
||||
encryptionAvailable: true,
|
||||
decryptString: () => {
|
||||
if (!keychainApproved) {
|
||||
throw new Error('userCanceledErr')
|
||||
}
|
||||
return 'token-alpha'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(linear.testConnection('legacy')).resolves.toEqual({
|
||||
ok: false,
|
||||
error:
|
||||
'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.'
|
||||
})
|
||||
expect(linear.getStatus().credentialError).toContain('Could not decrypt')
|
||||
|
||||
keychainApproved = true
|
||||
await expect(linear.testConnection('legacy')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
workspace: { id: 'org-alpha', organizationName: 'Alpha' }
|
||||
})
|
||||
expect(linear.getStatus().credentialError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats empty Linear token files as missing credentials', async () => {
|
||||
writeLegacyLinearToken(Buffer.alloc(0), {
|
||||
displayName: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
organizationName: 'Alpha'
|
||||
})
|
||||
const linear = await loadClientModule({ encryptionAvailable: false })
|
||||
|
||||
await expect(linear.testConnection('legacy')).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'No API key stored.'
|
||||
})
|
||||
|
||||
expect(linearClientMock).not.toHaveBeenCalled()
|
||||
expect(linear.getStatus()).toMatchObject({ connected: false })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ import { LinearClient, AuthenticationLinearError } from '@linear/sdk'
|
|||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
CredentialDecryptionError,
|
||||
credentialFileHasContent,
|
||||
readStoredCredentialToken
|
||||
} from '../integration-credential-file'
|
||||
import type {
|
||||
LinearConnectionStatus,
|
||||
LinearViewer,
|
||||
|
|
@ -58,6 +63,9 @@ export type LinearClientForWorkspace = {
|
|||
}
|
||||
|
||||
let cachedTokens = new Map<string, string>()
|
||||
// Why: decrypt failures are recorded per workspace so getStatus can explain
|
||||
// failing reads without re-touching the keychain on every status poll.
|
||||
const credentialErrors = new Map<string, string>()
|
||||
let cachedLegacyViewer: LinearViewer | null = null
|
||||
let legacyViewerLoadedFromDisk = false
|
||||
let cachedWorkspaceFile: LinearWorkspaceFile | null = null
|
||||
|
|
@ -327,6 +335,7 @@ function saveWorkspaceToken(workspaceId: string, apiKey: string): void {
|
|||
const tokenPath = getWorkspaceTokenPath(workspaceId)
|
||||
writeEncryptedToken(tokenPath, apiKey)
|
||||
cachedTokens.set(workspaceId, apiKey)
|
||||
credentialErrors.delete(workspaceId)
|
||||
}
|
||||
|
||||
// Backward-compatible export for the legacy single-workspace storage path.
|
||||
|
|
@ -352,12 +361,17 @@ export function loadToken(options: { force?: boolean; workspaceId?: string } = {
|
|||
}
|
||||
try {
|
||||
const raw = readFileSync(tokenPath)
|
||||
const token = safeStorage.isEncryptionAvailable()
|
||||
? safeStorage.decryptString(raw)
|
||||
: raw.toString('utf-8')
|
||||
cachedTokens.set(workspaceId, token)
|
||||
const token = readStoredCredentialToken('Linear', raw)
|
||||
if (token) {
|
||||
cachedTokens.set(workspaceId, token)
|
||||
}
|
||||
credentialErrors.delete(workspaceId)
|
||||
return token
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialDecryptionError) {
|
||||
credentialErrors.set(workspaceId, error.message)
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -369,11 +383,12 @@ export function hasStoredToken(workspaceId?: string): boolean {
|
|||
if (cachedTokens.has(workspaceId)) {
|
||||
return true
|
||||
}
|
||||
return existsSync(getWorkspaceTokenPath(workspaceId))
|
||||
return credentialFileHasContent(getWorkspaceTokenPath(workspaceId))
|
||||
}
|
||||
|
||||
function clearTokenFile(workspaceId: string): void {
|
||||
cachedTokens.delete(workspaceId)
|
||||
credentialErrors.delete(workspaceId)
|
||||
try {
|
||||
unlinkSync(getWorkspaceTokenPath(workspaceId))
|
||||
} catch {
|
||||
|
|
@ -388,6 +403,7 @@ export function clearToken(workspaceId?: string): void {
|
|||
clearTokenFile(workspace.id)
|
||||
}
|
||||
cachedTokens = new Map()
|
||||
credentialErrors.clear()
|
||||
cachedLegacyViewer = null
|
||||
legacyViewerLoadedFromDisk = false
|
||||
cachedWorkspaceFile = emptyWorkspaceFile()
|
||||
|
|
@ -594,12 +610,17 @@ export function getStatus(): LinearConnectionStatus {
|
|||
state.workspaces[0] ??
|
||||
null
|
||||
|
||||
const credentialError = state.workspaces
|
||||
.map((workspace) => credentialErrors.get(workspace.id))
|
||||
.find((message) => message !== undefined)
|
||||
|
||||
return {
|
||||
connected: state.workspaces.length > 0,
|
||||
viewer: activeWorkspace,
|
||||
workspaces: state.workspaces,
|
||||
activeWorkspaceId: state.activeWorkspaceId,
|
||||
selectedWorkspaceId: state.selectedWorkspaceId
|
||||
selectedWorkspaceId: state.selectedWorkspaceId,
|
||||
...(credentialError ? { credentialError } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -612,7 +633,13 @@ export async function testConnection(
|
|||
if (!resolvedWorkspaceId) {
|
||||
return { ok: false, error: 'No API key stored.' }
|
||||
}
|
||||
const token = loadToken({ force: true, workspaceId: resolvedWorkspaceId })
|
||||
let token: string | null
|
||||
try {
|
||||
token = loadToken({ force: true, workspaceId: resolvedWorkspaceId })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Test failed'
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
if (!token) {
|
||||
return { ok: false, error: 'No API key stored.' }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LinearClientForWorkspace } from './client'
|
||||
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
|
||||
|
||||
const rawRequest = vi.fn()
|
||||
const getClients = vi.fn()
|
||||
const clearToken = vi.fn()
|
||||
const isAuthError = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
acquire: vi.fn().mockResolvedValue(undefined),
|
||||
release: vi.fn(),
|
||||
getClients: (...args: unknown[]) => getClients(...args),
|
||||
isAuthError: vi.fn().mockReturnValue(false),
|
||||
isAuthError: (...args: unknown[]) => isAuthError(...args),
|
||||
clearToken: (...args: unknown[]) => clearToken(...args)
|
||||
}))
|
||||
|
||||
|
|
@ -87,6 +89,7 @@ function datedIssues(prefix: string, count: number, startMs: number, startIndex
|
|||
describe('Linear issue queries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isAuthError.mockReturnValue(false)
|
||||
getClients.mockReturnValue([makeEntry()])
|
||||
})
|
||||
|
||||
|
|
@ -167,6 +170,20 @@ describe('Linear issue queries', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('surfaces Linear credential decrypt errors on active issue reads and mutations', async () => {
|
||||
const error = new Error(credentialDecryptionMessage('Linear'))
|
||||
getClients.mockImplementation(() => {
|
||||
throw error
|
||||
})
|
||||
const { createIssue, listIssues, searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('bug', 20, 'workspace-1')).rejects.toThrow(error.message)
|
||||
await expect(listIssues('all', 20, 'workspace-1')).rejects.toThrow(error.message)
|
||||
await expect(createIssue('team-1', 'Fix auth', undefined, 'workspace-1')).rejects.toThrow(
|
||||
error.message
|
||||
)
|
||||
})
|
||||
|
||||
it('marks plain list results as having more when Linear has a next page', async () => {
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: { issues: { nodes: [rawIssue('LIN-1')], pageInfo: { hasNextPage: true } } }
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LinearClientForWorkspace } from './client'
|
||||
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
|
||||
|
||||
const rawRequest = vi.fn()
|
||||
const getClients = vi.fn()
|
||||
const clearToken = vi.fn()
|
||||
const isAuthError = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
acquire: vi.fn().mockResolvedValue(undefined),
|
||||
release: vi.fn(),
|
||||
getClients: (...args: unknown[]) => getClients(...args),
|
||||
isAuthError: vi.fn().mockReturnValue(false),
|
||||
isAuthError: (...args: unknown[]) => isAuthError(...args),
|
||||
clearToken: (...args: unknown[]) => clearToken(...args)
|
||||
}))
|
||||
|
||||
|
|
@ -144,9 +146,20 @@ describe('Linear project queries', () => {
|
|||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
isAuthError.mockReturnValue(false)
|
||||
getClients.mockReturnValue([makeEntry()])
|
||||
})
|
||||
|
||||
it('surfaces Linear credential decrypt errors on active project metadata reads', async () => {
|
||||
const error = new Error(credentialDecryptionMessage('Linear'))
|
||||
getClients.mockImplementation(() => {
|
||||
throw error
|
||||
})
|
||||
const { listProjects } = await import('./projects')
|
||||
|
||||
await expect(listProjects(undefined, 20, 'workspace-1', true)).rejects.toThrow(error.message)
|
||||
})
|
||||
|
||||
it('lets manual project issue refresh bypass older in-flight reads', async () => {
|
||||
const staleRequest = deferred<ReturnType<typeof projectIssuesResponse>>()
|
||||
const refreshRequest = deferred<ReturnType<typeof projectIssuesResponse>>()
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LinearClientForWorkspace } from './client'
|
||||
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
|
||||
|
||||
const getClients = vi.fn()
|
||||
const clearToken = vi.fn()
|
||||
const isAuthError = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
acquire: vi.fn().mockResolvedValue(undefined),
|
||||
release: vi.fn(),
|
||||
getClients: (...args: unknown[]) => getClients(...args),
|
||||
isAuthError: vi.fn().mockReturnValue(false),
|
||||
isAuthError: (...args: unknown[]) => isAuthError(...args),
|
||||
clearToken: (...args: unknown[]) => clearToken(...args)
|
||||
}))
|
||||
|
||||
|
|
@ -65,6 +67,7 @@ function makeEntry(
|
|||
describe('Linear teams', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isAuthError.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('fetches every page of teams for a workspace', async () => {
|
||||
|
|
@ -97,4 +100,14 @@ describe('Linear teams', () => {
|
|||
{ id: 'team-b', workspaceId: 'workspace-2', workspaceName: 'Beta' }
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces Linear credential decrypt errors on active team reads', async () => {
|
||||
const error = new Error(credentialDecryptionMessage('Linear'))
|
||||
getClients.mockImplementation(() => {
|
||||
throw error
|
||||
})
|
||||
const { listTeams } = await import('./teams')
|
||||
|
||||
await expect(listTeams('workspace-1')).rejects.toThrow(error.message)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3735,11 +3735,12 @@ export default function TaskPage(): React.JSX.Element {
|
|||
? linearCustomViewContentsLoading
|
||||
: linearLoading
|
||||
const activeLinearIssueError =
|
||||
selectedLinearProject && linearProjectTab === 'issues'
|
||||
linearStatus.credentialError ??
|
||||
(selectedLinearProject && linearProjectTab === 'issues'
|
||||
? linearProjectIssuesError
|
||||
: selectedLinearCustomView?.model === 'issue'
|
||||
? linearCustomViewContentsError
|
||||
: linearError
|
||||
: linearError)
|
||||
const activeLinearIssueCollectionErrors =
|
||||
selectedLinearProject && linearProjectTab === 'issues'
|
||||
? linearProjectIssuesResult.errors
|
||||
|
|
@ -8337,9 +8338,9 @@ export default function TaskPage(): React.JSX.Element {
|
|||
className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek"
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
>
|
||||
{jiraError ? (
|
||||
{(jiraStatus.credentialError ?? jiraError) ? (
|
||||
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
|
||||
{jiraError}
|
||||
{jiraStatus.credentialError ?? jiraError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -8354,7 +8355,10 @@ export default function TaskPage(): React.JSX.Element {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{!jiraLoading && jiraIssues.length === 0 && !jiraError ? (
|
||||
{!jiraLoading &&
|
||||
jiraIssues.length === 0 &&
|
||||
!jiraError &&
|
||||
!jiraStatus.credentialError ? (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{translate('auto.components.TaskPage.eba87f2edb', 'No Jira issues found')}
|
||||
|
|
|
|||
|
|
@ -770,6 +770,12 @@ export function IntegrationsPane(): React.JSX.Element {
|
|||
'Add Linear access to browse and link issues.'
|
||||
)}
|
||||
</p>
|
||||
{linearStatus.credentialError ? (
|
||||
<p className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span className="min-w-0">{linearStatus.credentialError}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{linearStatus.connected ? (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { JiraIssue } from '../../../../shared/types'
|
||||
import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors'
|
||||
import { createJiraSlice } from './jira'
|
||||
|
||||
const jiraGetIssue = vi.fn()
|
||||
const jiraListIssues = vi.fn()
|
||||
const jiraSearchIssues = vi.fn()
|
||||
const jiraStatus = vi.fn()
|
||||
|
||||
vi.mock('@/runtime/runtime-jira-client', () => ({
|
||||
jiraConnect: vi.fn(),
|
||||
jiraDisconnect: vi.fn(),
|
||||
jiraGetIssue: (...args: unknown[]) => jiraGetIssue(...args),
|
||||
jiraListIssues: (...args: unknown[]) => jiraListIssues(...args),
|
||||
jiraSearchIssues: (...args: unknown[]) => jiraSearchIssues(...args),
|
||||
jiraSelectSite: vi.fn(),
|
||||
jiraStatus: (...args: unknown[]) => jiraStatus(...args),
|
||||
jiraTestConnection: vi.fn()
|
||||
}))
|
||||
|
||||
function createTestStore() {
|
||||
return create<AppState>()(
|
||||
(...a) =>
|
||||
({
|
||||
settings: null,
|
||||
...createJiraSlice(...a)
|
||||
}) as AppState
|
||||
)
|
||||
}
|
||||
|
||||
function issue(key: string): JiraIssue {
|
||||
return {
|
||||
id: key,
|
||||
key,
|
||||
title: key,
|
||||
url: `https://example.atlassian.net/browse/${key}`,
|
||||
siteId: 'site-1',
|
||||
siteName: 'Example Jira',
|
||||
project: { id: '10000', key: 'ALP', name: 'Alpha', siteId: 'site-1' },
|
||||
issueType: { id: '10001', name: 'Bug' },
|
||||
status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' },
|
||||
labels: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z'
|
||||
}
|
||||
}
|
||||
|
||||
describe('createJiraSlice credential errors', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('serves fresh Jira cache without reading credentials', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' },
|
||||
jiraSearchCache: {
|
||||
'site-1::list::assigned::30': { data: [issue('ALP-1')], fetchedAt: Date.now() }
|
||||
}
|
||||
})
|
||||
|
||||
await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toMatchObject([
|
||||
{ key: 'ALP-1' }
|
||||
])
|
||||
|
||||
expect(jiraListIssues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns an empty list and surfaces the credential error in status on Jira decrypt errors', async () => {
|
||||
const store = createTestStore()
|
||||
const error = new Error(credentialDecryptionMessage('Jira'))
|
||||
store.setState({
|
||||
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' }
|
||||
})
|
||||
jiraStatus.mockResolvedValue({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedSiteId: 'site-1',
|
||||
credentialError: error.message
|
||||
})
|
||||
jiraSearchIssues.mockRejectedValueOnce(error)
|
||||
|
||||
await expect(store.getState().searchJiraIssues('project = ALP', 30)).resolves.toEqual([])
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().jiraStatus.credentialError).toBe(error.message)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null and refreshes status on Jira decrypt errors during detail refresh', async () => {
|
||||
const store = createTestStore()
|
||||
const error = new Error(credentialDecryptionMessage('Jira'))
|
||||
store.setState({
|
||||
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' },
|
||||
jiraIssueCache: {
|
||||
'site-1::ALP-1': { data: issue('ALP-1'), fetchedAt: 1 }
|
||||
}
|
||||
})
|
||||
jiraStatus.mockResolvedValue({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedSiteId: 'site-1',
|
||||
credentialError: error.message
|
||||
})
|
||||
jiraGetIssue.mockRejectedValueOnce(error)
|
||||
|
||||
await expect(store.getState().fetchJiraIssue('ALP-1', 'site-1')).resolves.toBeNull()
|
||||
expect(jiraStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
JiraViewer
|
||||
} from '../../../../shared/types'
|
||||
import type { CacheEntry } from './github'
|
||||
import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors'
|
||||
import {
|
||||
jiraConnect,
|
||||
jiraDisconnect,
|
||||
|
|
@ -99,6 +100,7 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
|
|||
const prev = get().jiraStatus
|
||||
if (
|
||||
prev.connected !== status.connected ||
|
||||
prev.credentialError !== status.credentialError ||
|
||||
prev.viewer?.email !== status.viewer?.email ||
|
||||
getSelectedSiteId(prev) !== getSelectedSiteId(status) ||
|
||||
(prev.sites?.length ?? 0) !== (status.sites?.length ?? 0)
|
||||
|
|
@ -187,7 +189,9 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[jira] fetchJiraIssue failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error)) {
|
||||
void get().checkJiraConnection()
|
||||
} else if (looksLikeAuthError(error)) {
|
||||
set({ jiraStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
return null
|
||||
|
|
@ -222,7 +226,9 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[jira] searchJiraIssues failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error)) {
|
||||
void get().checkJiraConnection()
|
||||
} else if (looksLikeAuthError(error)) {
|
||||
set({ jiraStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
return []
|
||||
|
|
@ -257,7 +263,9 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[jira] listJiraIssues failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error)) {
|
||||
void get().checkJiraConnection()
|
||||
} else if (looksLikeAuthError(error)) {
|
||||
set({ jiraStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
LinearTeam,
|
||||
LinearViewer
|
||||
} from '../../../../shared/types'
|
||||
import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors'
|
||||
import { createLinearSlice } from './linear'
|
||||
|
||||
const linearStatus = vi.fn()
|
||||
|
|
@ -195,6 +196,45 @@ describe('createLinearSlice caching', () => {
|
|||
).resolves.toMatchObject({ items: [{ id: 'LIN-CACHED' }] })
|
||||
})
|
||||
|
||||
it('returns an empty list and refreshes status on Linear decrypt errors during list reads', async () => {
|
||||
const store = createTestStore()
|
||||
const error = new Error(credentialDecryptionMessage('Linear'))
|
||||
store.setState({
|
||||
linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' },
|
||||
linearListCache: {
|
||||
'workspace-1::list::all::36': { data: { items: [issue('LIN-CACHED')] }, fetchedAt: 1 }
|
||||
}
|
||||
})
|
||||
linearStatus.mockResolvedValue({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
credentialError: error.message
|
||||
})
|
||||
linearListIssues.mockRejectedValueOnce(error)
|
||||
|
||||
await expect(
|
||||
store.getState().listLinearIssues('all', 36, { force: true })
|
||||
).resolves.toMatchObject({ items: [] })
|
||||
expect(linearStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns an empty list and refreshes status on Linear decrypt errors during searches', async () => {
|
||||
const store = createTestStore()
|
||||
const error = new Error(credentialDecryptionMessage('Linear'))
|
||||
store.setState({
|
||||
linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' }
|
||||
})
|
||||
linearStatus.mockResolvedValue({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
credentialError: error.message
|
||||
})
|
||||
linearSearchIssues.mockRejectedValueOnce(error)
|
||||
|
||||
await expect(store.getState().searchLinearIssues('bug', 36)).resolves.toEqual([])
|
||||
expect(linearStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces scoped project issue failures alongside cached rows', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
|
|
@ -216,6 +256,33 @@ describe('createLinearSlice caching', () => {
|
|||
expect(linearListProjectIssues.mock.calls[0][4]).toEqual({ force: true })
|
||||
})
|
||||
|
||||
it('surfaces Linear decrypt errors as workspace errors on project issue reads', async () => {
|
||||
const store = createTestStore()
|
||||
const error = new Error(credentialDecryptionMessage('Linear'))
|
||||
store.setState({
|
||||
linearProjectIssueCache: {
|
||||
'workspace-1::project-issues::project-1::20': {
|
||||
data: { items: [issue('LIN-CACHED')] },
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
linearStatus.mockResolvedValue({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
credentialError: error.message
|
||||
})
|
||||
linearListProjectIssues.mockRejectedValueOnce(error)
|
||||
|
||||
await expect(
|
||||
store.getState().listLinearProjectIssues('project-1', 'workspace-1', 20, { force: true })
|
||||
).resolves.toMatchObject({
|
||||
items: [{ id: 'LIN-CACHED' }],
|
||||
errors: [{ message: error.message }]
|
||||
})
|
||||
expect(linearStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the largest smaller cached project issue limit when expansion fails', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
} from '../../../../shared/types'
|
||||
import type { CacheEntry } from './github'
|
||||
import { clampLinearIssueListLimit } from '../../../../shared/linear-issue-read-limits'
|
||||
import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors'
|
||||
import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata'
|
||||
import {
|
||||
linearConnect,
|
||||
|
|
@ -198,6 +199,7 @@ function linearWorkspaceSignature(workspace: LinearWorkspace): string {
|
|||
function linearStatusScopeSignature(status: LinearConnectionStatus): string {
|
||||
return JSON.stringify({
|
||||
connected: status.connected,
|
||||
credentialError: status.credentialError ?? null,
|
||||
activeWorkspaceId: status.activeWorkspaceId ?? null,
|
||||
selectedWorkspaceId: getSelectedWorkspaceId(status),
|
||||
viewer: status.viewer
|
||||
|
|
@ -710,7 +712,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] fetchLinearIssue failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
return null
|
||||
|
|
@ -793,7 +795,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] searchLinearIssues failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) {
|
||||
if (!shouldRefreshStatusAfterRead(workspaceId)) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
|
|
@ -857,7 +859,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearIssues failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) {
|
||||
if (!shouldRefreshStatusAfterRead(workspaceId)) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
|
|
@ -920,7 +922,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearTeams failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) {
|
||||
if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId)) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
|
|
@ -986,7 +988,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearProjects failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error)) {
|
||||
void get().checkLinearConnection(true)
|
||||
} else if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
const fallback =
|
||||
|
|
@ -1039,7 +1043,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] fetchLinearProject failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error)) {
|
||||
void get().checkLinearConnection(true)
|
||||
} else if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
if (options?.force) {
|
||||
|
|
@ -1107,7 +1113,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearProjectIssues failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error)) {
|
||||
void get().checkLinearConnection(true)
|
||||
} else if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
const fallback =
|
||||
|
|
@ -1173,7 +1181,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearCustomViews failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error)) {
|
||||
void get().checkLinearConnection(true)
|
||||
} else if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
const fallback =
|
||||
|
|
@ -1227,7 +1237,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] fetchLinearCustomView failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error)) {
|
||||
void get().checkLinearConnection(true)
|
||||
} else if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
if (options?.force) {
|
||||
|
|
@ -1295,7 +1307,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearCustomViewIssues failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error)) {
|
||||
void get().checkLinearConnection(true)
|
||||
} else if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
const fallback =
|
||||
|
|
@ -1354,7 +1368,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearCustomViewProjects failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
if (isIntegrationCredentialDecryptionError(error)) {
|
||||
void get().checkLinearConnection(true)
|
||||
} else if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
const fallback =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
export type IntegrationCredentialService = 'Linear' | 'Jira'
|
||||
|
||||
export function credentialDecryptionMessage(service: IntegrationCredentialService): string {
|
||||
return `Could not decrypt saved ${service} credential. Approve Keychain access or reconnect ${service}.`
|
||||
}
|
||||
|
||||
// Why: decrypt errors cross IPC/RPC boundaries where only the message
|
||||
// survives serialization, so detection matches on the canonical message.
|
||||
export function isIntegrationCredentialDecryptionError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return (
|
||||
message.includes(credentialDecryptionMessage('Linear')) ||
|
||||
message.includes(credentialDecryptionMessage('Jira'))
|
||||
)
|
||||
}
|
||||
|
|
@ -21,6 +21,9 @@ export type JiraConnectionStatus = {
|
|||
sites?: JiraSite[]
|
||||
activeSiteId?: string | null
|
||||
selectedSiteId?: JiraSiteSelection | null
|
||||
// Set when a stored token file exists but could not be decrypted, so the
|
||||
// UI can explain reads failing while the connection still looks saved.
|
||||
credentialError?: string
|
||||
}
|
||||
|
||||
export type JiraProject = {
|
||||
|
|
|
|||
|
|
@ -1161,6 +1161,9 @@ export type LinearConnectionStatus = {
|
|||
workspaces?: LinearWorkspace[]
|
||||
activeWorkspaceId?: string | null
|
||||
selectedWorkspaceId?: LinearWorkspaceSelection | null
|
||||
// Set when a stored token file exists but could not be decrypted, so the
|
||||
// UI can explain reads failing while the connection still looks saved.
|
||||
credentialError?: string
|
||||
}
|
||||
|
||||
export type LinearIssue = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue