diff --git a/src/main/orca-profiles/profile-cloud-service-connect-overlap.test.ts b/src/main/orca-profiles/profile-cloud-service-connect-overlap.test.ts new file mode 100644 index 00000000000..3b4504903b6 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service-connect-overlap.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' + +const { beginOrcaCloudPkceFlowMock, exchangeOrcaCloudAuthCodeMock, safeStorageMock } = vi.hoisted( + () => ({ + beginOrcaCloudPkceFlowMock: vi.fn(), + exchangeOrcaCloudAuthCodeMock: vi.fn(), + safeStorageMock: { + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) + } + }) +) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +vi.mock('./profile-cloud-pkce', () => ({ + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock +})) + +vi.mock('./profile-cloud-client', () => ({ + createOrcaCloudProfile: vi.fn(), + exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock, + revokeOrcaCloudSession: vi.fn(), + selectOrcaCloudOrg: vi.fn() +})) + +import { connectCurrentOrcaProfile, getCurrentOrcaProfileAuthStatus } from './profile-cloud-service' + +const earlierCloud: OrcaProfileCloudSummary = { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + displayName: 'Nina', + linkedAt: 10 +} + +const laterCloud: OrcaProfileCloudSummary = { + ...earlierCloud, + cloudProfileId: 'cloud-profile-2', + userId: 'user-2', + email: 'ada@example.com' +} + +const capabilities: OrcaCloudCapabilities = { + flags: { share: true }, + refreshedAt: 11 +} + +const organizations: OrcaCloudOrgSummary[] = [{ orgId: 'org-1', name: 'Acme', role: 'Admin' }] + +describe('Orca cloud overlapping connect', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-connect-overlap-')) + beginOrcaCloudPkceFlowMock.mockReset() + exchangeOrcaCloudAuthCodeMock.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8')) + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('does not let an earlier sign-in overwrite a later successful connect', async () => { + let finishFirst!: (value: { + code: string + codeVerifier: string + nonce: string + redirectUri: string + state: string + }) => void + beginOrcaCloudPkceFlowMock + .mockReturnValueOnce( + new Promise((resolve) => { + finishFirst = resolve + }) + ) + .mockResolvedValueOnce({ + code: 'later-code', + codeVerifier: 'later-verifier', + nonce: 'later-nonce', + redirectUri: 'http://127.0.0.1:4101/auth/callback', + state: 'later-state' + }) + exchangeOrcaCloudAuthCodeMock.mockImplementation(async (_config, args) => ({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3_600_000, + cloud: args.code === 'later-code' ? laterCloud : earlierCloud, + organizations, + capabilities + })) + + const first = connectCurrentOrcaProfile(userDataPath) + const later = connectCurrentOrcaProfile(userDataPath) + await expect(later).resolves.toMatchObject({ status: 'connected' }) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).cloud?.email).toBe('ada@example.com') + + finishFirst({ + code: 'earlier-code', + codeVerifier: 'earlier-verifier', + nonce: 'earlier-nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'earlier-state' + }) + await expect(first).resolves.toMatchObject({ status: 'cancelled' }) + expect(exchangeOrcaCloudAuthCodeMock).toHaveBeenCalledTimes(1) + expect(exchangeOrcaCloudAuthCodeMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ code: 'later-code' }) + ) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).cloud?.email).toBe('ada@example.com') + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-service.ts b/src/main/orca-profiles/profile-cloud-service.ts index e17b3d2c6f1..66354b567b1 100644 --- a/src/main/orca-profiles/profile-cloud-service.ts +++ b/src/main/orca-profiles/profile-cloud-service.ts @@ -36,6 +36,9 @@ import { selectCloudOrgWithMutationFence } from './profile-cloud-org-selection' export { refreshCurrentOrcaProfileAuth } from './profile-cloud-capability-refresh' +let nextCloudConnectAttempt = 0 +let linkedCloudConnectAttempt = 0 + function isUserCancelledAuthError(message: string): boolean { return message === 'orca_cloud_auth_timeout' || message === 'orca_cloud_auth_denied' } @@ -73,14 +76,28 @@ export async function connectCurrentOrcaProfile( } } + const attempt = ++nextCloudConnectAttempt try { const code = await beginOrcaCloudPkceFlow(configState.config, active.profile.id) + if (attempt < linkedCloudConnectAttempt) { + return { + status: 'cancelled', + auth: getCurrentOrcaProfileAuthStatus(userDataPath) + } + } const exchange = await exchangeOrcaCloudAuthCode(configState.config, { ...code, localProfileId: active.profile.id }) + if (attempt < linkedCloudConnectAttempt) { + return { + status: 'cancelled', + auth: getCurrentOrcaProfileAuthStatus(userDataPath) + } + } saveOrcaCloudSessionExchange(active.profile.id, userDataPath, exchange) const list = linkOrcaProfileToCloud(active.profile.id, exchange.cloud, userDataPath) + linkedCloudConnectAttempt = attempt return { status: 'connected', auth: getCurrentOrcaProfileAuthStatus(userDataPath), diff --git a/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx b/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx index 6624b07da33..de37ab0401c 100644 --- a/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx +++ b/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx @@ -63,7 +63,6 @@ function storeState(): Record { closeArtifactsPage: mocks.closePage, connectCurrentOrcaProfile: mocks.connect, orcaProfileAuthStatus: mocks.authStatus, - refreshCurrentOrcaProfileAuth: mocks.refreshAuth, settings: mocks.settings, updateSettings: mocks.updateSettings, diff --git a/src/renderer/src/store/slices/orca-profiles-auth-actions-connect-overlap.test.ts b/src/renderer/src/store/slices/orca-profiles-auth-actions-connect-overlap.test.ts new file mode 100644 index 00000000000..b54d52892c3 --- /dev/null +++ b/src/renderer/src/store/slices/orca-profiles-auth-actions-connect-overlap.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + ConnectCurrentOrcaProfileResult, + OrcaProfileAuthStatus, + OrcaProfileListState +} from '../../../../shared/orca-profiles' +import { createTestStore } from './store-test-helpers' + +const { toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({ + toastErrorMock: vi.fn(), + toastSuccessMock: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { + error: toastErrorMock, + info: vi.fn(), + success: toastSuccessMock, + warning: vi.fn() + } +})) + +const listState: OrcaProfileListState = { + activeProfileId: 'local-default', + profiles: [ + { + id: 'local-default', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } + ] +} + +const connectedCloud = { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + linkedAt: 3 +} + +const connectedAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: connectedCloud, + organizations: [{ orgId: 'org-1', name: 'Acme', role: 'Admin' }], + capabilities: { flags: { share: true }, refreshedAt: 4 } +} + +const orcaProfilesApi = { + connectCurrent: vi.fn() +} + +describe('orca profile overlapping connect actions', () => { + beforeEach(() => { + vi.resetAllMocks() + toastErrorMock.mockReset() + toastSuccessMock.mockReset() + vi.stubGlobal('window', { + api: { orcaProfiles: orcaProfilesApi } + }) + }) + + it('keeps the later sign-in and one success toast when both waits complete', async () => { + const laterCloud = { ...connectedCloud, userId: 'user-2', email: 'ada@example.com' } + const laterAuthStatus: OrcaProfileAuthStatus = { + ...connectedAuthStatus, + cloud: laterCloud + } + const earlierConnected: ConnectCurrentOrcaProfileResult = { + status: 'connected', + auth: connectedAuthStatus, + activeProfileId: 'local-default', + profiles: [{ ...listState.profiles[0], kind: 'cloud-linked', cloud: connectedCloud }] + } + const laterConnected: ConnectCurrentOrcaProfileResult = { + status: 'connected', + auth: laterAuthStatus, + activeProfileId: 'local-default', + profiles: [{ ...listState.profiles[0], kind: 'cloud-linked', cloud: laterCloud }] + } + let finishFirst!: (value: ConnectCurrentOrcaProfileResult) => void + orcaProfilesApi.connectCurrent + .mockReturnValueOnce( + new Promise((resolve) => { + finishFirst = resolve + }) + ) + .mockResolvedValueOnce(laterConnected) + const store = createTestStore() + + const first = store.getState().connectCurrentOrcaProfile() + const second = store.getState().connectCurrentOrcaProfile() + await expect(second).resolves.toEqual(laterConnected) + finishFirst(earlierConnected) + await expect(first).resolves.toEqual(earlierConnected) + expect(toastSuccessMock).toHaveBeenCalledOnce() + expect(toastErrorMock).not.toHaveBeenCalled() + expect(store.getState().orcaProfileAuthStatus).toEqual(laterAuthStatus) + expect(store.getState().orcaProfiles).toEqual(laterConnected.profiles) + }) +}) diff --git a/src/renderer/src/store/slices/orca-profiles-auth-actions.ts b/src/renderer/src/store/slices/orca-profiles-auth-actions.ts index 0a1474b9148..7222fb67922 100644 --- a/src/renderer/src/store/slices/orca-profiles-auth-actions.ts +++ b/src/renderer/src/store/slices/orca-profiles-auth-actions.ts @@ -29,186 +29,209 @@ export const createOrcaProfilesAuthActions: StateCreator< [], [], OrcaProfilesAuthActions -> = (set, get) => ({ - createCloudLinkedOrcaProfile: async (args) => { - try { - const result = await window.api.orcaProfiles.createCloudLinked(args) - set({ - orcaProfileAuthStatus: result.auth, - ...(result.status === 'created' - ? { - activeOrcaProfileId: result.activeProfileId, - orcaProfiles: result.profiles - } - : {}) - }) - if (result.status === 'created') { - toast.success( - translate('auto.store.slices.orca.profiles.319d7cf39b', 'Cloud profile created') - ) - } else if (result.status === 'reconnect-required') { - toast.error( - translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') - ) - } else if (result.status === 'failed') { +> = (set, get) => { + let nextConnectAttempt = 0 + let appliedConnectAttempt = 0 + + return { + createCloudLinkedOrcaProfile: async (args) => { + try { + const result = await window.api.orcaProfiles.createCloudLinked(args) + set({ + orcaProfileAuthStatus: result.auth, + ...(result.status === 'created' + ? { + activeOrcaProfileId: result.activeProfileId, + orcaProfiles: result.profiles + } + : {}) + }) + if (result.status === 'created') { + toast.success( + translate('auto.store.slices.orca.profiles.319d7cf39b', 'Cloud profile created') + ) + } else if (result.status === 'reconnect-required') { + toast.error( + translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') + ) + } else if (result.status === 'failed') { + toast.error( + translate( + 'auto.store.slices.orca.profiles.f0c9e11a6d', + 'Failed to create cloud profile' + ), + { description: result.error } + ) + } + return result + } catch (err) { + console.error('Failed to create Orca cloud profile:', err) toast.error( translate('auto.store.slices.orca.profiles.f0c9e11a6d', 'Failed to create cloud profile'), - { description: result.error } - ) - } - return result - } catch (err) { - console.error('Failed to create Orca cloud profile:', err) - toast.error( - translate('auto.store.slices.orca.profiles.f0c9e11a6d', 'Failed to create cloud profile'), - { - description: err instanceof Error ? err.message : String(err) - } - ) - return null - } - }, - - connectCurrentOrcaProfile: async () => { - try { - // Why: a pending browser callback must not block retry. Another click - // starts a second PKCE wait; the earlier tab can still complete. - const alreadyConnected = get().orcaProfileAuthStatus?.state === 'connected' - const result = await window.api.orcaProfiles.connectCurrent() - set({ - orcaProfileAuthStatus: result.auth, - ...(result.status === 'connected' - ? { - activeOrcaProfileId: result.activeProfileId, - orcaProfiles: result.profiles - } - : {}) - }) - if (result.status === 'unconfigured') { - toast.error( - translate( - 'auto.store.slices.orca.profiles.8b8fa73174', - 'Orca Cloud sign-in is not configured' - ), - { - description: result.auth.setupMessage - } - ) - } else if ( - result.status === 'failed' && - !alreadyConnected && - result.auth.state !== 'connected' - ) { - toast.error( - translate('auto.store.slices.orca.profiles.33290e88ed', 'Failed to connect profile'), - { description: result.error } - ) - } else if (result.status === 'connected' && !alreadyConnected) { - toast.success(translate('auto.store.slices.orca.profiles.9fcb07a796', 'Profile connected')) - } - return result - } catch (err) { - console.error('Failed to connect Orca profile:', err) - if (get().orcaProfileAuthStatus?.state !== 'connected') { - toast.error( - translate('auto.store.slices.orca.profiles.33290e88ed', 'Failed to connect profile'), { description: err instanceof Error ? err.message : String(err) } ) + return null } - return null - } - }, + }, - refreshCurrentOrcaProfileAuth: async () => { - try { - const result = await window.api.orcaProfiles.refreshAuth() - set({ - orcaProfileAuthStatus: result.auth, - ...(result.status === 'refreshed' - ? { - activeOrcaProfileId: result.activeProfileId, - orcaProfiles: result.profiles + connectCurrentOrcaProfile: async () => { + const attempt = ++nextConnectAttempt + try { + // Why: a pending browser callback must not block retry. Another click + // starts a second PKCE wait; an older wait is ignored after a newer + // one has already linked. + const result = await window.api.orcaProfiles.connectCurrent() + if (attempt < appliedConnectAttempt) { + return result + } + const alreadyConnected = get().orcaProfileAuthStatus?.state === 'connected' + set({ + orcaProfileAuthStatus: result.auth, + ...(result.status === 'connected' + ? { + activeOrcaProfileId: result.activeProfileId, + orcaProfiles: result.profiles + } + : {}) + }) + if (result.status === 'connected') { + appliedConnectAttempt = attempt + if (!alreadyConnected) { + toast.success( + translate('auto.store.slices.orca.profiles.9fcb07a796', 'Profile connected') + ) + } + } else if (result.status === 'unconfigured') { + toast.error( + translate( + 'auto.store.slices.orca.profiles.8b8fa73174', + 'Orca Cloud sign-in is not configured' + ), + { + description: result.auth.setupMessage } - : {}) - }) - if (result.status === 'reconnect-required') { - toast.error( - translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') - ) - } else if (result.status === 'failed') { + ) + } else if (result.status === 'failed' && !alreadyConnected) { + toast.error( + translate('auto.store.slices.orca.profiles.33290e88ed', 'Failed to connect profile'), + { description: result.error } + ) + } + return result + } catch (err) { + console.error('Failed to connect Orca profile:', err) + if ( + attempt >= appliedConnectAttempt && + get().orcaProfileAuthStatus?.state !== 'connected' + ) { + toast.error( + translate('auto.store.slices.orca.profiles.33290e88ed', 'Failed to connect profile'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + } + return null + } + }, + + refreshCurrentOrcaProfileAuth: async () => { + try { + const result = await window.api.orcaProfiles.refreshAuth() + set({ + orcaProfileAuthStatus: result.auth, + ...(result.status === 'refreshed' + ? { + activeOrcaProfileId: result.activeProfileId, + orcaProfiles: result.profiles + } + : {}) + }) + if (result.status === 'reconnect-required') { + toast.error( + translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') + ) + } else if (result.status === 'failed') { + toast.error( + translate( + 'auto.store.slices.orca.profiles.2f6c78a039', + 'Failed to refresh profile auth' + ), + { description: result.error } + ) + } + return result + } catch (err) { + console.error('Failed to refresh Orca profile auth:', err) toast.error( translate('auto.store.slices.orca.profiles.2f6c78a039', 'Failed to refresh profile auth'), - { description: result.error } + { + description: err instanceof Error ? err.message : String(err) + } ) + return null } - return result - } catch (err) { - console.error('Failed to refresh Orca profile auth:', err) - toast.error( - translate('auto.store.slices.orca.profiles.2f6c78a039', 'Failed to refresh profile auth'), - { - description: err instanceof Error ? err.message : String(err) - } - ) - return null - } - }, + }, - signOutCurrentOrcaProfile: async () => { - try { - const result = await window.api.orcaProfiles.signOutCurrent() - set({ - activeOrcaProfileId: result.activeProfileId, - orcaProfiles: result.profiles, - orcaProfileAuthStatus: result.auth - }) - toast.success( - translate('auto.store.slices.orca.profiles.a37b5e6d37', 'Signed out of profile') - ) - return result - } catch (err) { - console.error('Failed to sign out of Orca profile:', err) - toast.error(translate('auto.store.slices.orca.profiles.83600521e7', 'Failed to sign out'), { - description: err instanceof Error ? err.message : String(err) - }) - return null - } - }, - - selectOrcaProfileOrg: async (orgId) => { - try { - const result = await window.api.orcaProfiles.selectOrg({ orgId }) - set({ - orcaProfileAuthStatus: result.auth, - ...(result.status === 'selected' - ? { - activeOrcaProfileId: result.activeProfileId, - orcaProfiles: result.profiles - } - : {}) - }) - if (result.status === 'reconnect-required') { - toast.error( - translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') + signOutCurrentOrcaProfile: async () => { + try { + const result = await window.api.orcaProfiles.signOutCurrent() + set({ + activeOrcaProfileId: result.activeProfileId, + orcaProfiles: result.profiles, + orcaProfileAuthStatus: result.auth + }) + toast.success( + translate('auto.store.slices.orca.profiles.a37b5e6d37', 'Signed out of profile') ) - } else if (result.status === 'failed') { + return result + } catch (err) { + console.error('Failed to sign out of Orca profile:', err) + toast.error(translate('auto.store.slices.orca.profiles.83600521e7', 'Failed to sign out'), { + description: err instanceof Error ? err.message : String(err) + }) + return null + } + }, + + selectOrcaProfileOrg: async (orgId) => { + try { + const result = await window.api.orcaProfiles.selectOrg({ orgId }) + set({ + orcaProfileAuthStatus: result.auth, + ...(result.status === 'selected' + ? { + activeOrcaProfileId: result.activeProfileId, + orcaProfiles: result.profiles + } + : {}) + }) + if (result.status === 'reconnect-required') { + toast.error( + translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') + ) + } else if (result.status === 'failed') { + toast.error( + translate( + 'auto.store.slices.orca.profiles.76deec8f58', + 'Failed to switch organization' + ), + { description: result.error } + ) + } + return result + } catch (err) { + console.error('Failed to switch Orca profile org:', err) toast.error( translate('auto.store.slices.orca.profiles.76deec8f58', 'Failed to switch organization'), - { description: result.error } + { + description: err instanceof Error ? err.message : String(err) + } ) + return null } - return result - } catch (err) { - console.error('Failed to switch Orca profile org:', err) - toast.error( - translate('auto.store.slices.orca.profiles.76deec8f58', 'Failed to switch organization'), - { - description: err instanceof Error ? err.message : String(err) - } - ) - return null } } -}) +}