fix: address pr-bug-scan validated finding from #4683 (#5151)

Isolated CredentialDecryptionError per-item in Linear getClients (client.ts:518) and Jira getClients (client.ts:373) on the 'all' selection so one bad credential no longer collapses healthy workspaces

Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
This commit is contained in:
buf0-bot[bot]
2026-06-11 12:52:48 -07:00
committed by Jinwoo-H
co-authored by orca-bug-scan-bot
parent 4bf580c639
commit 43df43d0d5
4 changed files with 191 additions and 10 deletions
+72
View File
@@ -50,6 +50,37 @@ function writeJiraFiles(siteId: string, token: string | Buffer): void {
writeFileSync(tokenPathForSite(siteId), token)
}
function writeMultiSiteFiles(
sites: { id: string; token: string | Buffer }[],
selectedSiteId: string
): void {
const orcaDir = join(tempHome, '.orca')
mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true })
writeFileSync(
join(orcaDir, 'jira-sites.json'),
JSON.stringify(
{
version: 1,
activeSiteId: sites[0]?.id ?? null,
selectedSiteId,
sites: sites.map((site) => ({
id: site.id,
siteUrl: `https://${site.id}.atlassian.net`,
email: `${site.id}@example.com`,
displayName: site.id,
accountId: `account-${site.id}`
}))
},
null,
2
),
{ encoding: 'utf-8' }
)
for (const site of sites) {
writeFileSync(tokenPathForSite(site.id), site.token)
}
}
async function loadClientModule(options: SafeStorageMockOptions = {}) {
vi.resetModules()
vi.doMock('electron', () => ({
@@ -237,4 +268,45 @@ describe('Jira client credential storage', () => {
expect(fetchMock).not.toHaveBeenCalled()
expect(jira.getStatus()).toMatchObject({ connected: false })
})
it('keeps healthy sites under the "all" selection when one site cannot be decrypted', async () => {
writeMultiSiteFiles(
[
{ id: 'good', token: 'token-good' },
{ id: 'bad', token: Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]) }
],
'all'
)
const jira = await loadClientModule({
encryptionAvailable: true,
// Why: only the binary "bad" token throws on decrypt; the plaintext
// "good" token falls back through the legacy path.
decryptString: () => {
throw new Error('userCanceledErr')
}
})
const clients = jira.getClients('all')
expect(clients.map((client) => client.site.id)).toEqual(['good'])
// The bad site's decrypt error is still recorded for the status banner.
expect(jira.getStatus().credentialError).toContain('Could not decrypt')
})
it('rethrows the decrypt error for a specific site selection', async () => {
writeMultiSiteFiles(
[
{ id: 'good', token: 'token-good' },
{ id: 'bad', token: Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]) }
],
'bad'
)
const jira = await loadClientModule({
encryptionAvailable: true,
decryptString: () => {
throw new Error('userCanceledErr')
}
})
expect(() => jira.getClients('bad')).toThrow('Could not decrypt')
})
})
+18 -5
View File
@@ -373,13 +373,26 @@ export async function jiraRequest<T>(
export function getClients(selection?: JiraSiteSelection | null): JiraClientForSite[] {
const file = getSiteFile()
const selected = selection ?? file.selectedSiteId ?? file.activeSiteId
const sites =
selected === 'all'
? file.sites
: file.sites.filter((site) => site.id === (selected ?? file.activeSiteId))
const isAllSelection = selected === 'all'
const sites = isAllSelection
? file.sites
: file.sites.filter((site) => site.id === (selected ?? file.activeSiteId))
return sites.flatMap((site) => {
const token = readToken(site.id)
let token: string | null
try {
token = readToken(site.id)
} catch (error) {
// Why: under an 'all' selection one un-decryptable site must not collapse
// reads for the healthy ones. readToken already recorded the per-site
// credentialError for getStatus to surface, so skip this site like a
// missing token. A specific-site selection still rethrows so the renderer
// can surface the decrypt banner promptly.
if (isAllSelection && error instanceof CredentialDecryptionError) {
return []
}
throw error
}
return token ? [{ site, authorization: authHeader(site.email, token) }] : []
})
}
+83
View File
@@ -34,6 +34,42 @@ function writeLegacyLinearToken(token: string | Buffer, viewer: Record<string, u
})
}
function workspaceTokenPath(workspaceId: string): string {
return join(
tempHome,
'.orca',
'linear-tokens',
`${Buffer.from(workspaceId).toString('base64url')}.enc`
)
}
function writeMultiWorkspaceFiles(
workspaces: { id: string; token: string | Buffer }[],
selectedWorkspaceId: string
): void {
const orcaDir = join(tempHome, '.orca')
mkdirSync(join(orcaDir, 'linear-tokens'), { recursive: true })
writeFileSync(
join(orcaDir, 'linear-workspaces.json'),
JSON.stringify({
version: 1,
activeWorkspaceId: workspaces[0]?.id ?? null,
selectedWorkspaceId,
workspaces: workspaces.map((workspace) => ({
id: workspace.id,
organizationId: workspace.id,
organizationName: workspace.id,
displayName: 'Ada',
email: 'ada@example.com'
}))
}),
{ encoding: 'utf-8' }
)
for (const workspace of workspaces) {
writeFileSync(workspaceTokenPath(workspace.id), workspace.token)
}
}
async function loadClientModule(options: SafeStorageMockOptions = {}) {
vi.resetModules()
linearClientMock = vi.fn(function LinearClient(
@@ -302,4 +338,51 @@ describe('Linear client workspace storage', () => {
expect(linearClientMock).not.toHaveBeenCalled()
expect(linear.getStatus()).toMatchObject({ connected: false })
})
it('keeps healthy workspaces under the "all" selection when one cannot be decrypted', async () => {
writeMultiWorkspaceFiles(
[
{ id: 'good', token: 'token-good' },
{ id: 'bad', token: Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]) }
],
'all'
)
fixtures.set('token-good', {
displayName: 'Ada',
email: 'ada@example.com',
organizationId: 'good',
organizationName: 'good',
organizationUrlKey: 'good'
})
const linear = await loadClientModule({
encryptionAvailable: true,
// Why: the plaintext "token-good" falls back through the legacy path;
// the binary "bad" token throws CredentialDecryptionError.
decryptString: () => {
throw new Error('userCanceledErr')
}
})
const clients = linear.getClients('all')
expect(clients.map((client) => client.workspace.id)).toEqual(['good'])
expect(linear.getStatus().credentialError).toContain('Could not decrypt')
})
it('rethrows the decrypt error for a specific workspace selection', async () => {
writeMultiWorkspaceFiles(
[
{ id: 'good', token: 'token-good' },
{ id: 'bad', token: Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]) }
],
'bad'
)
const linear = await loadClientModule({
encryptionAvailable: true,
decryptString: () => {
throw new Error('userCanceledErr')
}
})
expect(() => linear.getClients('bad')).toThrow('Could not decrypt')
})
})
+18 -5
View File
@@ -519,14 +519,27 @@ export function getClients(
workspaceId?: LinearWorkspaceSelection | null
): LinearClientForWorkspace[] {
const state = getWorkspaceState()
const selectedWorkspaces =
workspaceId === 'all'
? state.workspaces
: state.workspaces.filter((workspace) => workspace.id === resolveWorkspaceId(workspaceId))
const isAllSelection = workspaceId === 'all'
const selectedWorkspaces = isAllSelection
? state.workspaces
: state.workspaces.filter((workspace) => workspace.id === resolveWorkspaceId(workspaceId))
const clients: LinearClientForWorkspace[] = []
for (const workspace of selectedWorkspaces) {
const token = loadToken({ force: true, workspaceId: workspace.id })
let token: string | null
try {
token = loadToken({ force: true, workspaceId: workspace.id })
} catch (error) {
// Why: under an 'all' selection one un-decryptable workspace must not
// collapse reads for the healthy ones. loadToken already recorded the
// per-workspace credentialError for getStatus to surface, so skip this
// workspace like a missing token. A specific-workspace selection still
// rethrows so the renderer can surface the decrypt banner promptly.
if (isAllSelection && error instanceof CredentialDecryptionError) {
continue
}
throw error
}
if (!token) {
continue
}