Refresh Jira and Linear status after credential errors (#5169)
* fix: address pr-bug-scan validated finding from #4683 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 * Refresh Jira and Linear status to clear stale credential errors Ensure stale credential decryption errors are cleared from the store status once a successful API read completes. By updating the check in shouldRefreshStatusAfterRead to trigger when a credentialError is currently set, successful issue or list fetches will trigger a status check and remove stale error flags. --------- Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
This commit is contained in:
parent
fcc0afa8cd
commit
845ea2c68c
|
|
@ -226,4 +226,78 @@ describe('createJiraSlice credential errors', () => {
|
|||
await expect(store.getState().fetchJiraIssue('ALP-1', 'site-1')).resolves.toBeNull()
|
||||
expect(jiraStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes status after all-site Jira list reads partially succeed', async () => {
|
||||
const store = createTestStore()
|
||||
const error = new Error(credentialDecryptionMessage('Jira'))
|
||||
store.setState({
|
||||
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'all' }
|
||||
})
|
||||
jiraListIssues.mockResolvedValueOnce([issue('ALP-1')])
|
||||
jiraStatus.mockResolvedValueOnce({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedSiteId: 'all',
|
||||
credentialError: error.message
|
||||
})
|
||||
|
||||
await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toMatchObject([
|
||||
{ key: 'ALP-1' }
|
||||
])
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().jiraStatus.credentialError).toBe(error.message)
|
||||
})
|
||||
})
|
||||
|
||||
it('clears stale Jira credential errors after successful site list reads', async () => {
|
||||
const store = createTestStore()
|
||||
const staleError = credentialDecryptionMessage('Jira')
|
||||
store.setState({
|
||||
jiraStatus: {
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedSiteId: 'site-1',
|
||||
credentialError: staleError
|
||||
}
|
||||
})
|
||||
jiraListIssues.mockResolvedValueOnce([issue('ALP-1')])
|
||||
jiraStatus.mockResolvedValueOnce({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedSiteId: 'site-1'
|
||||
})
|
||||
|
||||
await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toMatchObject([
|
||||
{ key: 'ALP-1' }
|
||||
])
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().jiraStatus.credentialError).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears stale Jira credential errors after successful issue detail reads', async () => {
|
||||
const store = createTestStore()
|
||||
const staleError = credentialDecryptionMessage('Jira')
|
||||
store.setState({
|
||||
jiraStatus: {
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedSiteId: 'site-1',
|
||||
credentialError: staleError
|
||||
}
|
||||
})
|
||||
jiraGetIssue.mockResolvedValueOnce(issue('ALP-1'))
|
||||
jiraStatus.mockResolvedValueOnce({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedSiteId: 'site-1'
|
||||
})
|
||||
|
||||
await expect(store.getState().fetchJiraIssue('ALP-1', 'site-1')).resolves.toMatchObject({
|
||||
key: 'ALP-1'
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().jiraStatus.credentialError).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -69,6 +69,15 @@ function getSelectedSiteId(status: JiraConnectionStatus): JiraSiteSelection | nu
|
|||
return status.selectedSiteId ?? status.activeSiteId ?? null
|
||||
}
|
||||
|
||||
function shouldRefreshStatusAfterRead(
|
||||
siteId: JiraSiteSelection | null | undefined,
|
||||
status: JiraConnectionStatus
|
||||
): boolean {
|
||||
// Why: 'all' reads can hide per-site decrypt failures, and a visible
|
||||
// credential error may have been cleared by a successful credential read.
|
||||
return siteId === 'all' || status.credentialError !== undefined
|
||||
}
|
||||
|
||||
function clearJiraInflight(): void {
|
||||
inflightIssueRequests.clear()
|
||||
inflightSearchRequests.clear()
|
||||
|
|
@ -324,7 +333,9 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
|
|||
isIntegrationCredentialDecryptionError(error) &&
|
||||
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
|
||||
) {
|
||||
void get().checkJiraConnection()
|
||||
if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) {
|
||||
void get().checkJiraConnection()
|
||||
}
|
||||
} else if (
|
||||
looksLikeAuthError(error) &&
|
||||
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
|
||||
|
|
@ -337,6 +348,12 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
|
|||
if (inflightIssueRequests.get(issueCacheKey) === entry) {
|
||||
inflightIssueRequests.delete(issueCacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(siteId, get().jiraStatus) &&
|
||||
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
|
||||
) {
|
||||
void get().checkJiraConnection()
|
||||
}
|
||||
})
|
||||
entry = { promise, contextKey, mutationGeneration: requestMutationGeneration }
|
||||
inflightIssueRequests.set(issueCacheKey, entry)
|
||||
|
|
@ -382,7 +399,9 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
|
|||
isIntegrationCredentialDecryptionError(error) &&
|
||||
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
|
||||
) {
|
||||
void get().checkJiraConnection()
|
||||
if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) {
|
||||
void get().checkJiraConnection()
|
||||
}
|
||||
} else if (
|
||||
looksLikeAuthError(error) &&
|
||||
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
|
||||
|
|
@ -395,6 +414,12 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
|
|||
if (inflightSearchRequests.get(cacheKey) === entry) {
|
||||
inflightSearchRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(siteId, get().jiraStatus) &&
|
||||
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
|
||||
) {
|
||||
void get().checkJiraConnection()
|
||||
}
|
||||
})
|
||||
entry = { promise, contextKey, mutationGeneration: requestMutationGeneration }
|
||||
inflightSearchRequests.set(cacheKey, entry)
|
||||
|
|
@ -440,7 +465,9 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
|
|||
isIntegrationCredentialDecryptionError(error) &&
|
||||
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
|
||||
) {
|
||||
void get().checkJiraConnection()
|
||||
if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) {
|
||||
void get().checkJiraConnection()
|
||||
}
|
||||
} else if (
|
||||
looksLikeAuthError(error) &&
|
||||
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
|
||||
|
|
@ -453,6 +480,12 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
|
|||
if (inflightListRequests.get(cacheKey) === entry) {
|
||||
inflightListRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(siteId, get().jiraStatus) &&
|
||||
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
|
||||
) {
|
||||
void get().checkJiraConnection()
|
||||
}
|
||||
})
|
||||
entry = { promise, contextKey, mutationGeneration: requestMutationGeneration }
|
||||
inflightListRequests.set(cacheKey, entry)
|
||||
|
|
|
|||
|
|
@ -235,6 +235,86 @@ describe('createLinearSlice caching', () => {
|
|||
expect(linearStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears stale Linear credential errors after successful workspace list reads', async () => {
|
||||
const store = createTestStore()
|
||||
const staleError = credentialDecryptionMessage('Linear')
|
||||
store.setState({
|
||||
linearStatus: {
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedWorkspaceId: 'workspace-1',
|
||||
credentialError: staleError
|
||||
}
|
||||
})
|
||||
linearListIssues.mockResolvedValueOnce({ items: [issue('LIN-OK')] })
|
||||
linearStatus.mockResolvedValueOnce({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedWorkspaceId: 'workspace-1'
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().listLinearIssues('all', 36, { force: true })
|
||||
).resolves.toMatchObject({ items: [{ id: 'LIN-OK' }] })
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().linearStatus.credentialError).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears stale Linear credential errors after successful issue detail reads', async () => {
|
||||
const store = createTestStore()
|
||||
const staleError = credentialDecryptionMessage('Linear')
|
||||
store.setState({
|
||||
linearStatus: {
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedWorkspaceId: 'workspace-1',
|
||||
credentialError: staleError
|
||||
}
|
||||
})
|
||||
linearGetIssue.mockResolvedValueOnce(issue('LIN-OK'))
|
||||
linearStatus.mockResolvedValueOnce({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedWorkspaceId: 'workspace-1'
|
||||
})
|
||||
|
||||
await expect(store.getState().fetchLinearIssue('LIN-OK', 'workspace-1')).resolves.toMatchObject(
|
||||
{
|
||||
id: 'LIN-OK'
|
||||
}
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().linearStatus.credentialError).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears stale Linear credential errors after successful scoped collection reads', async () => {
|
||||
const store = createTestStore()
|
||||
const staleError = credentialDecryptionMessage('Linear')
|
||||
store.setState({
|
||||
linearStatus: {
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedWorkspaceId: 'workspace-1',
|
||||
credentialError: staleError
|
||||
}
|
||||
})
|
||||
linearListProjectIssues.mockResolvedValueOnce({ items: [issue('LIN-OK')] })
|
||||
linearStatus.mockResolvedValueOnce({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedWorkspaceId: 'workspace-1'
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().listLinearProjectIssues('project-1', 'workspace-1', 20, { force: true })
|
||||
).resolves.toMatchObject({ items: [{ id: 'LIN-OK' }] })
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().linearStatus.credentialError).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces scoped project issue failures alongside cached rows', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
|
|
@ -484,14 +564,28 @@ describe('createLinearSlice caching', () => {
|
|||
expect(linearListCustomViews.mock.calls[0][4]).toEqual({ force: true })
|
||||
})
|
||||
|
||||
it('fetches custom views by exact id for saved-context restore', async () => {
|
||||
it('fetches custom views by exact id and clears stale credential errors', async () => {
|
||||
const store = createTestStore()
|
||||
const staleError = credentialDecryptionMessage('Linear')
|
||||
store.setState({
|
||||
linearStatus: {
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedWorkspaceId: 'workspace-1',
|
||||
credentialError: staleError
|
||||
}
|
||||
})
|
||||
linearGetCustomView.mockResolvedValueOnce({
|
||||
id: 'view-1',
|
||||
name: 'Burn views',
|
||||
model: 'project',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
linearStatus.mockResolvedValueOnce({
|
||||
connected: true,
|
||||
viewer: null,
|
||||
selectedWorkspaceId: 'workspace-1'
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().fetchLinearCustomView('view-1', 'workspace-1', 'project', { force: true })
|
||||
|
|
@ -500,6 +594,9 @@ describe('createLinearSlice caching', () => {
|
|||
expect(linearGetCustomView).toHaveBeenCalledWith(null, 'view-1', 'project', 'workspace-1', {
|
||||
force: true
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().linearStatus.credentialError).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('fails forced exact custom-view validation instead of reopening stale cache', async () => {
|
||||
|
|
|
|||
|
|
@ -250,9 +250,12 @@ function invalidateLinearCaches(): void {
|
|||
}
|
||||
|
||||
function shouldRefreshStatusAfterRead(
|
||||
workspaceId: LinearWorkspaceSelection | null | undefined
|
||||
workspaceId: LinearWorkspaceSelection | null | undefined,
|
||||
status: LinearConnectionStatus
|
||||
): boolean {
|
||||
return workspaceId === 'all'
|
||||
// Why: 'all' reads can hide per-workspace decrypt failures, and a visible
|
||||
// credential error may have been cleared by a successful credential read.
|
||||
return workspaceId === 'all' || status.credentialError !== undefined
|
||||
}
|
||||
|
||||
function linearCollectionCacheKey(
|
||||
|
|
@ -851,6 +854,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
if (inflightIssueRequests.get(issueCacheKey) === entry) {
|
||||
inflightIssueRequests.delete(issueCacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
requestMutationGeneration,
|
||||
get().settings
|
||||
)
|
||||
) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
})
|
||||
|
||||
entry = {
|
||||
|
|
@ -964,7 +978,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
get().settings
|
||||
)
|
||||
) {
|
||||
if (!shouldRefreshStatusAfterRead(workspaceId)) {
|
||||
if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
return []
|
||||
|
|
@ -976,7 +990,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
inflightSearchRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(workspaceId) &&
|
||||
shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
|
|
@ -1059,7 +1073,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
get().settings
|
||||
)
|
||||
) {
|
||||
if (!shouldRefreshStatusAfterRead(workspaceId)) {
|
||||
if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
return emptyLinearCollection<LinearIssue>()
|
||||
|
|
@ -1071,7 +1085,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
inflightListRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(workspaceId) &&
|
||||
shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
|
|
@ -1153,7 +1167,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
get().settings
|
||||
)
|
||||
) {
|
||||
if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId)) {
|
||||
if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus)) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
return []
|
||||
|
|
@ -1165,7 +1179,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
inflightTeamRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(resolvedWorkspaceId) &&
|
||||
shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
|
|
@ -1261,7 +1275,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
inflightProjectRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(resolvedWorkspaceId) &&
|
||||
shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
|
|
@ -1353,6 +1367,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
if (inflightProjectDetailRequests.get(cacheKey) === entry) {
|
||||
inflightProjectDetailRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
requestMutationGeneration,
|
||||
get().settings
|
||||
)
|
||||
) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
})
|
||||
|
||||
entry = {
|
||||
|
|
@ -1449,6 +1474,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
if (inflightProjectIssueRequests.get(cacheKey) === entry) {
|
||||
inflightProjectIssueRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
requestMutationGeneration,
|
||||
get().settings
|
||||
)
|
||||
) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
})
|
||||
|
||||
entry = {
|
||||
|
|
@ -1535,7 +1571,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
inflightCustomViewRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(resolvedWorkspaceId) &&
|
||||
shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
|
|
@ -1627,6 +1663,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
if (inflightCustomViewDetailRequests.get(cacheKey) === entry) {
|
||||
inflightCustomViewDetailRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
requestMutationGeneration,
|
||||
get().settings
|
||||
)
|
||||
) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
})
|
||||
|
||||
entry = {
|
||||
|
|
@ -1723,6 +1770,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
if (inflightCustomViewIssueRequests.get(cacheKey) === entry) {
|
||||
inflightCustomViewIssueRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
requestMutationGeneration,
|
||||
get().settings
|
||||
)
|
||||
) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
})
|
||||
|
||||
entry = {
|
||||
|
|
@ -1801,6 +1859,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
if (inflightCustomViewProjectRequests.get(cacheKey) === entry) {
|
||||
inflightCustomViewProjectRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) &&
|
||||
canWriteLinearReadResult(
|
||||
contextKey,
|
||||
requestCacheGeneration,
|
||||
requestMutationGeneration,
|
||||
get().settings
|
||||
)
|
||||
) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
})
|
||||
|
||||
entry = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue