diff --git a/src/main/claude-accounts/legacy-shared-claude-auth-migration.test.ts b/src/main/claude-accounts/legacy-shared-claude-auth-migration.test.ts index 4defe542421..2ea024b4293 100644 --- a/src/main/claude-accounts/legacy-shared-claude-auth-migration.test.ts +++ b/src/main/claude-accounts/legacy-shared-claude-auth-migration.test.ts @@ -8,14 +8,19 @@ import { migrateLegacySharedClaudeAuth } from './legacy-shared-claude-auth-migration' +const ownership = vi.hoisted(() => ({ + resolve: (_id: string, path: string): string | null => path +})) + vi.mock('./managed-auth-path', () => ({ - resolveOwnedClaudeManagedAuthPath: (_id: string, path: string) => path + resolveOwnedClaudeManagedAuthPath: (id: string, path: string) => ownership.resolve(id, path) })) let root: string beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'orca-claude-migration-')) + ownership.resolve = (_id, path) => path }) afterEach(() => { @@ -98,6 +103,123 @@ describe('legacy shared Claude auth migration', () => { expect(writeManagedCredentials).not.toHaveBeenCalled() expect(fixture.marker()).toMatchObject({ outcome: 'already-present' }) }) + + it('refuses an untrusted account home without reading or mutating the shared store', async () => { + const fixture = createFixture() + const readLegacyKeychain = vi.fn(async () => fixture.shared) + const writeManagedCredentials = vi.fn(async () => {}) + ownership.resolve = () => null + + const outcome = await fixture.migrate({ readLegacyKeychain, writeManagedCredentials }) + + expect(outcome).toBe('unavailable') + expect(readLegacyKeychain).not.toHaveBeenCalled() + expect(writeManagedCredentials).not.toHaveBeenCalled() + expect(existsSync(fixture.markerPath)).toBe(false) + }) + + it('migrates the active account even when another account home is stale or deleted', async () => { + const fixture = createFixture() + const stale = { + ...fixture.account, + id: 'account-2', + email: 'other@example.com', + managedAuthPath: join(root, 'account-2', 'auth') + } + ownership.resolve = (id, path) => (id === stale.id ? null : path) + const writeManagedCredentials = vi.fn(async () => {}) + + const outcome = await fixture.migrate({ + accounts: [fixture.account, stale], + readLegacyOauthAccount: () => ({ emailAddress: 'user@example.com' }), + writeManagedCredentials + }) + + expect(outcome).toBe('migrated') + expect(writeManagedCredentials).toHaveBeenCalledWith(fixture.account, fixture.shared) + expect(fixture.marker()).toMatchObject({ outcome: 'migrated', accountId: fixture.account.id }) + }) + + it('keeps a deleted-home duplicate identity in the ambiguity gate', async () => { + const fixture = createFixture() + const duplicate = { + ...fixture.account, + id: 'account-2', + managedAuthPath: join(root, 'account-2', 'auth') + } + ownership.resolve = (id, path) => (id === duplicate.id ? null : path) + const writeManagedCredentials = vi.fn(async () => {}) + + const outcome = await fixture.migrate({ + accounts: [fixture.account, duplicate], + readLegacyOauthAccount: () => ({ emailAddress: 'user@example.com' }), + writeManagedCredentials + }) + + expect(outcome).toBe('ambiguous') + expect(writeManagedCredentials).not.toHaveBeenCalled() + expect(existsSync(fixture.markerPath)).toBe(false) + }) + + it('never clobbers a newer per-account credential', async () => { + const fixture = createFixture() + const newer = JSON.stringify({ claudeAiOauth: { accessToken: 'newer-token' } }) + const writeManagedCredentials = vi.fn(async () => {}) + + const outcome = await fixture.migrate({ + readLegacyOauthAccount: () => ({ emailAddress: 'user@example.com' }), + readManagedCredentials: async () => newer, + writeManagedCredentials + }) + + expect(outcome).toBe('already-present') + expect(writeManagedCredentials).not.toHaveBeenCalled() + expect(fixture.marker()).toMatchObject({ + outcome: 'already-present', + accountId: fixture.account.id + }) + }) + + it('is a full no-op once the generation marker is present', async () => { + const fixture = createFixture() + mkdirSync(join(root, 'metadata'), { recursive: true }) + writeFileSync( + fixture.markerPath, + `${JSON.stringify({ version: 1, completedAt: 1, outcome: 'migrated', accountId: fixture.account.id })}\n`, + 'utf-8' + ) + const readLegacyKeychain = vi.fn(async () => fixture.shared) + const readLegacyOauthAccount = vi.fn(() => ({ emailAddress: 'user@example.com' })) + const readManagedCredentials = vi.fn(async () => null) + const writeManagedCredentials = vi.fn(async () => {}) + + const outcome = await fixture.migrate({ + readLegacyKeychain, + readLegacyOauthAccount, + readManagedCredentials, + writeManagedCredentials + }) + + expect(outcome).toBe('already-present') + expect(readLegacyKeychain).not.toHaveBeenCalled() + expect(readLegacyOauthAccount).not.toHaveBeenCalled() + expect(readManagedCredentials).not.toHaveBeenCalled() + expect(writeManagedCredentials).not.toHaveBeenCalled() + }) + + it('never leaks the shared store to a non-matching account', async () => { + const fixture = createFixture() + const writeManagedCredentials = vi.fn(async () => {}) + + const outcome = await fixture.migrate({ + readLegacyOauthAccount: () => ({ emailAddress: 'other@example.com' }), + writeManagedCredentials + }) + + expect(outcome).toBe('ambiguous') + expect(writeManagedCredentials).not.toHaveBeenCalled() + expect(existsSync(fixture.markerPath)).toBe(false) + }) }) function createFixture() {