feat(aiturk): one-click sign-in for AITURK IDE (1.0.0-beta.14)
The onboarding and provider settings get a Sign in button: the IDE shows a short code, opens the turkservis.online approval page, and after approval saves the TurkServis provider itself and hands a separate member key to the AITURK CLI over stdin (aiturk login --key-stdin --keep-other-account). Keys never reach the renderer, logs, process arguments or URLs. Pasting an API key remains as the fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
1 parent
fe0a45a84f
commit
25d8c18c10
13 files changed
+1938
-15
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||
@@ -9,7 +9,12 @@ import { AiturkProvider } from './aiturk-provider'
|
||||
vi.mock('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() }))
|
||||
vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a> }))
|
||||
|
||||
afterEach(() => { cleanup(); vi.resetAllMocks() })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.resetAllMocks()
|
||||
vi.useRealTimers()
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
function setup() {
|
||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -17,6 +22,7 @@ function setup() {
|
||||
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||
|
||||
return { input, onConnected }
|
||||
}
|
||||
|
||||
@@ -47,3 +53,163 @@ describe('TurkServis provider setup', () => {
|
||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||
|
||||
function setupLogin(polls: unknown[]) {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const bridge = {
|
||||
start: vi.fn().mockResolvedValue({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: Date.now() + 600_000 }),
|
||||
poll: vi.fn(async () => polls.shift() ?? { state: 'pending' }),
|
||||
cancel: vi.fn().mockResolvedValue({ ok: true }),
|
||||
overwriteCli: vi.fn().mockResolvedValue({ cli: 'ok' })
|
||||
}
|
||||
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { aiturkLogin: bridge, openExternal }
|
||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||
render(<I18nProvider configClient={null}><AiturkProvider onConnected={onConnected} /></I18nProvider>)
|
||||
|
||||
return { bridge, onConnected, openExternal }
|
||||
}
|
||||
|
||||
const click = async (name: string) => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
const tick = () =>
|
||||
act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
})
|
||||
|
||||
describe('TurkServis one-click sign-in', () => {
|
||||
it('shows the code, lets the user reopen the page and polls every 3 seconds', async () => {
|
||||
const { bridge, onConnected, openExternal } = setupLogin([{ state: 'pending' }, { state: 'pending' }])
|
||||
await click('Giriş yap')
|
||||
|
||||
expect(bridge.start).toHaveBeenCalledOnce()
|
||||
expect(screen.getByLabelText('Giriş kodu').textContent).toBe('ABCD2345')
|
||||
expect(screen.getByText('Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.')).toBeTruthy()
|
||||
expect(bridge.poll).not.toHaveBeenCalled()
|
||||
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(1)
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||
|
||||
await click('Sayfayı yeniden aç')
|
||||
expect(openExternal).toHaveBeenCalledWith(URI)
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
// No key ever reaches the renderer through this flow.
|
||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||
expect(validateCustomEndpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onConnected once the main process reports the provider connected', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'pending' }, { state: 'connected', cli: 'ok' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
await tick()
|
||||
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||
expect(screen.queryByLabelText('Giriş kodu')).toBeNull()
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', ''],
|
||||
['outdated', 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.'],
|
||||
['failed', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'],
|
||||
['unknown-account', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.']
|
||||
])('connects and explains CLI result %s', async (cli, text) => {
|
||||
const { onConnected } = setupLogin([{ state: 'connected', cli }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('status')?.textContent ?? '').toBe(text)
|
||||
})
|
||||
|
||||
it('asks before replacing another CLI account, then replaces on confirm', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
|
||||
expect(screen.getByText('AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?')).toBeTruthy()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||
|
||||
await click('Değiştir')
|
||||
expect(bridge.overwriteCli).toHaveBeenCalledOnce()
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||
})
|
||||
|
||||
it('leaves the other CLI account alone when the user says so', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
await click('Olduğu gibi bırak')
|
||||
|
||||
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels on Vazgeç and returns to the sign-in button', async () => {
|
||||
const { bridge, onConnected } = setupLogin([])
|
||||
await click('Giriş yap')
|
||||
await click('Vazgeç')
|
||||
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: 'Giriş yap' })).toBeTruthy()
|
||||
await tick()
|
||||
expect(bridge.poll).not.toHaveBeenCalled()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an expired code and a failed start without connecting', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'expired' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
expect(screen.getByRole('alert').textContent).toBe('Giriş kodunun süresi doldu. Yeniden deneyin.')
|
||||
|
||||
bridge.start.mockRejectedValueOnce(
|
||||
new Error("Error invoking remote method 'aiturk:login:start': AiturkLoginError: aiturk-login:rate-limited")
|
||||
)
|
||||
await click('Giriş yap')
|
||||
expect(screen.getByRole('alert').textContent).toBe('Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.')
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revokes a pending login when the panel goes away', async () => {
|
||||
const { bridge } = setupLogin([])
|
||||
await click('Giriş yap')
|
||||
cleanup()
|
||||
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still connects with a pasted API key', async () => {
|
||||
vi.mocked(validateCustomEndpoint).mockResolvedValue({ ok: true, reachable: true, models: ['account/model-a'], message: '' })
|
||||
vi.mocked(saveCustomEndpoint).mockResolvedValue({ id: 'turkservis', endpoints: [], current: { base_url: 'https://ai.turkservis.online/v1', model: 'account/model-a', provider: 'turkservis' }, ok: true })
|
||||
const { bridge, onConnected } = setupLogin([])
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(screen.getByText('ya da API anahtarı yapıştırın')).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText('TurkServis API anahtarı'), { target: { value: 'test-key-never-real' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledOnce())
|
||||
expect(saveCustomEndpoint).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'test-key-never-real', model: 'account/model-a' }))
|
||||
expect(bridge.start).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user