Uzun sure acik kalan IDE'de "Telefon ekle" ilk istegi olu bir keep-alive baglantisinda kaybedebiliyordu; bulusma kaydi yayinlanamayinca kod yolu bes dakika boyunca "Kodla baglanma su an kullanilamiyor" kaliyordu (QR calisiyordu). - publishPairOffer: en cok 4 deneme (0,5 / 1,5 / 3 sn); ag hatasi, 5xx ve 429 yeniden denenir, 422 kalicidir; yaniti kaybolan denemeden sonraki 409 kaydin yerinde oldugu anlamina gelir (iptal jetonu yoktur, kayit kendiliginden biter). - Pencerede "Yeniden dene": ayni kod ve QR ile kaydi yeniden yayinlar. - Role oturumu kaydi ve "Giris yap" baslangici icin tek ek deneme (zaman asimi yeniden denenmez). - Gunluge yalniz "pair offer published / failed after N tries" duser; kod, lookup ve jeton yazilmaz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
657 lines
24 KiB
TypeScript
657 lines
24 KiB
TypeScript
/**
|
|
* Tests for electron/aiturk-login.ts — the one-click TurkServis sign-in.
|
|
* Everything is injected (fake fetch, fake backend, fake CLI, fake clock), so
|
|
* no network, browser or real CLI is touched. The fixture "keys" below are
|
|
* obviously fake placeholders in the right shape.
|
|
*/
|
|
|
|
import { EventEmitter } from 'node:events'
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
AITURK_CLI_KEY_HOLD_MS,
|
|
AITURK_LOGIN_START_RETRY_DELAY_MS,
|
|
type AiturkCliExit,
|
|
AiturkLoginError,
|
|
createAiturkCliRunner,
|
|
createAiturkLogin,
|
|
resolveAiturkCliPath
|
|
} from './aiturk-login'
|
|
|
|
const DEVICE_CODE = `tsdl_${'d'.repeat(43)}`
|
|
const IDE_KEY = 'tsk_FAKE-ide-key-for-tests'
|
|
const CLI_KEY = 'tsk_FAKE-cli-key-for-tests'
|
|
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
|
|
|
const startBody = (over: Record<string, unknown> = {}) => ({
|
|
loginId: '0f8fad5b-d9cb-469f-a165-70867728950e',
|
|
deviceCode: DEVICE_CODE,
|
|
userCode: 'ABCD2345',
|
|
expiresIn: 600,
|
|
interval: 3,
|
|
verificationUri: URI,
|
|
...over
|
|
})
|
|
|
|
const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'acct_test' }
|
|
|
|
function harness(
|
|
options: {
|
|
start?: { status: number; body?: unknown }
|
|
/** Thrown by successive `device-auth/start` calls before any answer is given. */
|
|
startErrors?: Error[]
|
|
polls?: ({ status: number; body?: unknown } | Error)[]
|
|
cliExits?: AiturkCliExit[]
|
|
validate?: unknown
|
|
save?: unknown
|
|
openExternal?: () => boolean
|
|
} = {}
|
|
) {
|
|
const calls: { url: string; init: any }[] = []
|
|
const polls = [...(options.polls ?? [])]
|
|
const startErrors = [...(options.startErrors ?? [])]
|
|
const cliExits = [...(options.cliExits ?? [0])]
|
|
const clock = { now: 1_000_000 }
|
|
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
|
|
|
|
const fetch = vi.fn(async (url: string, init: any) => {
|
|
calls.push({ url, init })
|
|
|
|
if (url.endsWith('/start')) {
|
|
const failure = startErrors.shift()
|
|
|
|
if (failure) {
|
|
throw failure
|
|
}
|
|
|
|
const r = options.start ?? { status: 201, body: startBody() }
|
|
|
|
return { status: r.status, json: async () => r.body }
|
|
}
|
|
|
|
if (url.endsWith('/poll')) {
|
|
const r = polls.shift() ?? { status: 200, body: { state: 'pending' } }
|
|
|
|
if (r instanceof Error) {
|
|
throw r
|
|
}
|
|
|
|
return { status: r.status, json: async () => r.body }
|
|
}
|
|
|
|
return { status: 200, json: async () => ({}) }
|
|
})
|
|
|
|
const backendRequest = vi.fn(async (request: any) => {
|
|
if (request.path.endsWith('/validate')) {
|
|
return options.validate ?? { ok: true, reachable: true, models: ['account/model-a', 'account/model-b'], message: '' }
|
|
}
|
|
|
|
return options.save ?? { ok: true, id: 'turkservis', endpoints: [] }
|
|
})
|
|
|
|
const runCli = vi.fn(async (_key: string, _options: { keepOtherAccount: boolean }) =>
|
|
cliExits.length ? (cliExits.shift() as AiturkCliExit) : 0
|
|
)
|
|
|
|
const openExternal = vi.fn(options.openExternal ?? (() => true))
|
|
|
|
const login = createAiturkLogin({
|
|
fetch,
|
|
openExternal,
|
|
hostname: () => 'TEST-PC',
|
|
backendRequest,
|
|
runCli,
|
|
now: () => clock.now,
|
|
setTimer: (fn, ms) => {
|
|
const timer = { fn, ms, cleared: false }
|
|
timers.push(timer)
|
|
|
|
return timer
|
|
},
|
|
clearTimer: handle => {
|
|
;(handle as { cleared: boolean }).cleared = true
|
|
},
|
|
timeoutSignal: () => undefined
|
|
})
|
|
|
|
return { login, fetch, calls, backendRequest, runCli, openExternal, clock, timers }
|
|
}
|
|
|
|
const leaks = (value: unknown) => /tsk_|tsdl_/.test(typeof value === 'string' ? value : JSON.stringify(value) ?? '')
|
|
|
|
describe('aiturk login: start', () => {
|
|
it('asks the server for a code, opens the approval page and returns no secret', async () => {
|
|
const h = harness()
|
|
const started = await h.login.start({ profile: 'work' })
|
|
|
|
expect(started).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
|
expect(leaks(started)).toBe(false)
|
|
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
|
|
|
const { url, init } = h.calls[0]
|
|
expect(url).toBe('https://turkservis.online/v1/aiturk/device-auth/start')
|
|
expect(init.method).toBe('POST')
|
|
expect(init.redirect).toBe('error')
|
|
expect(JSON.parse(init.body)).toEqual({ client: 'ide', device_name: 'TEST-PC', also_cli: true })
|
|
expect(init.headers.Authorization).toBeUndefined()
|
|
})
|
|
|
|
it.each([
|
|
'https://turkservis.online.evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
|
'http://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
|
'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e?next=//evil',
|
|
'https://turkservis.online/account/aiturk/device-login/../../x',
|
|
'https://evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
|
''
|
|
])('refuses a verification URL that is not exactly the approval page: %s', async verificationUri => {
|
|
const h = harness({ start: { status: 201, body: startBody({ verificationUri }) } })
|
|
|
|
const error = await h.login.start().catch(e => e)
|
|
expect(error).toBeInstanceOf(AiturkLoginError)
|
|
expect(error.message).toBe('aiturk-login:invalid-response')
|
|
expect(leaks(error.message)).toBe(false)
|
|
expect(h.openExternal).not.toHaveBeenCalled()
|
|
// The issued code is revoked instead of being left approvable.
|
|
expect(h.calls.at(-1)?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
|
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
|
})
|
|
|
|
it('maps 429 to rate-limited and other failures to unavailable', async () => {
|
|
await expect(harness({ start: { status: 429 } }).login.start()).rejects.toThrow('aiturk-login:rate-limited')
|
|
await expect(harness({ start: { status: 422 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
|
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
|
})
|
|
|
|
it('tries start once more after a thrown network error (first request of a long-idle process)', async () => {
|
|
const h = harness({ startErrors: [new TypeError('fetch failed')] })
|
|
const pending = h.login.start()
|
|
|
|
await vi.waitFor(() => expect(h.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
|
expect(AITURK_LOGIN_START_RETRY_DELAY_MS).toBe(500)
|
|
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
|
expect(h.openExternal).not.toHaveBeenCalled()
|
|
h.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
|
|
|
expect(await pending).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
|
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
|
// One code only: the approval page is opened once.
|
|
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
|
})
|
|
|
|
it('gives up after the second network error, and never retries an HTTP answer or a timeout', async () => {
|
|
const twice = harness({ startErrors: [new TypeError('fetch failed'), new TypeError('fetch failed')] })
|
|
const failing = twice.login.start().catch(e => e)
|
|
|
|
await vi.waitFor(() => expect(twice.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
|
twice.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
|
expect((await failing).message).toBe('aiturk-login:unavailable')
|
|
expect(twice.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
|
expect(twice.timers.filter(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toHaveLength(1)
|
|
|
|
for (const status of [500, 503, 429, 422]) {
|
|
const answered = harness({ start: { status } })
|
|
|
|
await expect(answered.login.start()).rejects.toThrow(/^aiturk-login:(?:unavailable|rate-limited)$/)
|
|
expect(answered.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
|
expect(answered.timers).toHaveLength(0)
|
|
}
|
|
|
|
const timedOut = harness({ startErrors: [new DOMException('The operation was aborted due to timeout', 'TimeoutError')] })
|
|
|
|
await expect(timedOut.login.start()).rejects.toThrow('aiturk-login:unavailable')
|
|
expect(timedOut.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
|
expect(timedOut.timers).toHaveLength(0)
|
|
})
|
|
|
|
it('rejects malformed codes', async () => {
|
|
await expect(
|
|
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
|
|
).rejects.toThrow('aiturk-login:invalid-response')
|
|
await expect(
|
|
harness({ start: { status: 201, body: startBody({ deviceCode: 'nope' }) } }).login.start()
|
|
).rejects.toThrow('aiturk-login:invalid-response')
|
|
})
|
|
|
|
it('keeps a single login: a second start revokes the first code', async () => {
|
|
const h = harness()
|
|
await h.login.start()
|
|
await h.login.start()
|
|
|
|
const urls = h.calls.map(c => c.url.split('/').pop())
|
|
expect(urls).toEqual(['start', 'cancel', 'start'])
|
|
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
|
})
|
|
|
|
it('cancels the code when the browser cannot be opened', async () => {
|
|
const h = harness({ openExternal: () => false })
|
|
|
|
await expect(h.login.start()).rejects.toThrow('aiturk-login:browser-failed')
|
|
expect(h.calls.at(-1)?.url).toMatch(/\/cancel$/)
|
|
})
|
|
})
|
|
|
|
describe('aiturk login: poll', () => {
|
|
it('reports pending while the user has not approved', async () => {
|
|
const h = harness({ polls: [{ status: 200, body: { state: 'pending' } }] })
|
|
await h.login.start()
|
|
|
|
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
|
expect(h.calls[1].url).toBe('https://turkservis.online/v1/aiturk/device-auth/poll')
|
|
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
|
expect(h.calls[1].init.redirect).toBe('error')
|
|
expect(h.backendRequest).not.toHaveBeenCalled()
|
|
expect(h.runCli).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps waiting through a transient network error', async () => {
|
|
const h = harness({ polls: [new Error(`boom ${DEVICE_CODE}`), { status: 503 }] })
|
|
await h.login.start()
|
|
|
|
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
|
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
|
})
|
|
|
|
it('reports expired on 401 and stops asking the server', async () => {
|
|
const h = harness({ polls: [{ status: 401, body: { detail: 'expired' } }] })
|
|
await h.login.start()
|
|
|
|
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
|
const count = h.fetch.mock.calls.length
|
|
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
|
expect(h.fetch.mock.calls.length).toBe(count)
|
|
})
|
|
|
|
it('expires by the clock without a request', async () => {
|
|
const h = harness()
|
|
await h.login.start()
|
|
h.clock.now += 600_000
|
|
|
|
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
|
expect(h.calls.filter(c => c.url.endsWith('/poll'))).toHaveLength(0)
|
|
})
|
|
|
|
it('on approval saves the provider and pipes ONLY the CLI key to the CLI', async () => {
|
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
|
await h.login.start({ profile: 'work', connectionId: 'conn-1' })
|
|
|
|
const result = await h.login.poll()
|
|
expect(result).toEqual({ state: 'connected', cli: 'ok' })
|
|
expect(leaks(result)).toBe(false)
|
|
|
|
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
|
expect(h.backendRequest.mock.calls[0][0]).toEqual({
|
|
connectionId: 'conn-1',
|
|
path: '/api/providers/custom-endpoints/validate',
|
|
method: 'POST',
|
|
body: {
|
|
id: 'turkservis',
|
|
name: 'TurkServis · AITURK',
|
|
base_url: 'https://ai.turkservis.online/v1',
|
|
api_key: IDE_KEY,
|
|
model: '',
|
|
discover_models: true,
|
|
make_default: true
|
|
}
|
|
})
|
|
expect(h.backendRequest.mock.calls[1][0]).toEqual({
|
|
profile: 'work',
|
|
connectionId: 'conn-1',
|
|
path: '/api/providers/custom-endpoints',
|
|
method: 'POST',
|
|
body: {
|
|
id: 'turkservis',
|
|
name: 'TurkServis · AITURK',
|
|
base_url: 'https://ai.turkservis.online/v1',
|
|
api_key: IDE_KEY,
|
|
model: 'account/model-a',
|
|
models: ['account/model-a', 'account/model-b'],
|
|
discover_models: true,
|
|
make_default: true
|
|
}
|
|
})
|
|
|
|
expect(h.runCli).toHaveBeenCalledExactlyOnceWith(CLI_KEY, { keepOtherAccount: true })
|
|
// No key ever rides a URL.
|
|
expect(h.calls.some(c => leaks(c.url))).toBe(false)
|
|
})
|
|
|
|
it('does not ask for the one-time keys again on later polls', async () => {
|
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
|
await h.login.start()
|
|
|
|
const first = await h.login.poll()
|
|
const fetches = h.fetch.mock.calls.length
|
|
const second = await h.login.poll()
|
|
|
|
expect(second).toEqual(first)
|
|
expect(h.fetch.mock.calls.length).toBe(fetches)
|
|
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
|
expect(h.runCli).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('shares one completion between overlapping polls', async () => {
|
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
|
await h.login.start()
|
|
|
|
const [a, b] = await Promise.all([h.login.poll(), h.login.poll()])
|
|
expect(a).toEqual({ state: 'connected', cli: 'ok' })
|
|
expect(b.state === 'connected' || b.state === 'pending').toBe(true)
|
|
expect(h.runCli).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it.each([
|
|
[0, 'ok'],
|
|
[2, 'outdated'],
|
|
[3, 'other-account'],
|
|
[4, 'unknown-account'],
|
|
[1, 'failed'],
|
|
[null, 'failed'],
|
|
['missing', 'missing']
|
|
] as const)('maps CLI exit %s to cli=%s', async (exit, cli) => {
|
|
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [exit] })
|
|
await h.login.start()
|
|
|
|
expect(await h.login.poll()).toEqual({ state: 'connected', cli })
|
|
})
|
|
|
|
it('treats a throwing CLI runner as failed without leaking', async () => {
|
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
|
h.runCli.mockRejectedValueOnce(new Error(`spawn failed ${CLI_KEY}`))
|
|
await h.login.start()
|
|
|
|
const result = await h.login.poll()
|
|
expect(result).toEqual({ state: 'connected', cli: 'failed' })
|
|
})
|
|
|
|
it('does not save or touch the CLI when the key is rejected by the backend', async () => {
|
|
const h = harness({
|
|
polls: [{ status: 200, body: approved }],
|
|
validate: { ok: false, reachable: true, models: [], message: `bad key ${IDE_KEY}` }
|
|
})
|
|
|
|
await h.login.start()
|
|
|
|
const result = await h.login.poll()
|
|
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
|
expect(leaks(result)).toBe(false)
|
|
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
|
expect(h.runCli).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not invent a model when the catalog is empty', async () => {
|
|
const h = harness({
|
|
polls: [{ status: 200, body: approved }],
|
|
validate: { ok: true, reachable: true, models: [], message: '' }
|
|
})
|
|
|
|
await h.login.start()
|
|
|
|
expect(await h.login.poll()).toEqual({ state: 'error', error: 'no-models' })
|
|
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('never surfaces backend error text (which could echo the key)', async () => {
|
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
|
h.backendRequest.mockRejectedValueOnce(new Error(`500: echoed ${IDE_KEY}`))
|
|
await h.login.start()
|
|
|
|
const result = await h.login.poll()
|
|
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
|
expect(leaks(result)).toBe(false)
|
|
})
|
|
|
|
it('rejects an approval whose key is malformed', async () => {
|
|
const h = harness({ polls: [{ status: 200, body: { state: 'approved', key: 'not a key', cliKey: CLI_KEY } }] })
|
|
await h.login.start()
|
|
|
|
expect(await h.login.poll()).toEqual({ state: 'error', error: 'invalid-response' })
|
|
expect(h.backendRequest).not.toHaveBeenCalled()
|
|
expect(h.runCli).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('aiturk login: other CLI account', () => {
|
|
async function otherAccount(overwriteExit: AiturkCliExit = 0) {
|
|
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [3, overwriteExit] })
|
|
await h.login.start()
|
|
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'other-account' })
|
|
|
|
return h
|
|
}
|
|
|
|
it('overwrites only after the user confirms, without the keep flag', async () => {
|
|
const h = await otherAccount()
|
|
|
|
const result = await h.login.overwriteCli()
|
|
expect(result).toEqual({ cli: 'ok' })
|
|
expect(leaks(result)).toBe(false)
|
|
expect(h.runCli).toHaveBeenLastCalledWith(CLI_KEY, { keepOtherAccount: false })
|
|
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'ok' })
|
|
|
|
// Single use: the held key is gone.
|
|
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
|
expect(h.runCli).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('reports an overwrite that still fails', async () => {
|
|
const h = await otherAccount(4)
|
|
|
|
expect(await h.login.overwriteCli()).toEqual({ cli: 'unknown-account' })
|
|
})
|
|
|
|
it('drops the held CLI key after five minutes', async () => {
|
|
const h = await otherAccount()
|
|
const hold = h.timers.find(t => t.ms === AITURK_CLI_KEY_HOLD_MS)
|
|
expect(hold).toBeDefined()
|
|
|
|
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
|
hold?.fn()
|
|
|
|
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
|
expect(h.runCli).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('refuses a stale held key even if the timer never fired', async () => {
|
|
const h = await otherAccount()
|
|
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
|
|
|
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
|
expect(h.runCli).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('drops the held CLI key on cancel', async () => {
|
|
const h = await otherAccount()
|
|
await h.login.cancel()
|
|
|
|
const error = await h.login.overwriteCli().catch(e => e)
|
|
expect(error.message).toBe('aiturk-login:nothing-to-overwrite')
|
|
expect(leaks(error.message)).toBe(false)
|
|
expect(h.runCli).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('has nothing to overwrite when the CLI simply succeeded', async () => {
|
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
|
await h.login.start()
|
|
await h.login.poll()
|
|
|
|
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
|
})
|
|
})
|
|
|
|
describe('aiturk login: cancel', () => {
|
|
it('revokes the code on the server and clears the session', async () => {
|
|
const h = harness()
|
|
await h.login.start()
|
|
|
|
expect(await h.login.cancel()).toEqual({ ok: true })
|
|
const last = h.calls.at(-1)
|
|
expect(last?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
|
expect(last?.init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
|
|
|
const count = h.fetch.mock.calls.length
|
|
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
|
expect(h.fetch.mock.calls.length).toBe(count)
|
|
})
|
|
|
|
it('is a no-op without a login and survives a failing server', async () => {
|
|
const h = harness()
|
|
expect(await h.login.cancel()).toEqual({ ok: true })
|
|
expect(h.fetch).not.toHaveBeenCalled()
|
|
|
|
await h.login.start()
|
|
h.fetch.mockRejectedValueOnce(new Error('offline'))
|
|
expect(await h.login.cancel()).toEqual({ ok: true })
|
|
})
|
|
})
|
|
|
|
describe('aiturk CLI runner', () => {
|
|
const CLI_PATH = 'C:\\Users\\Test User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd'
|
|
const env = { LOCALAPPDATA: 'C:\\Users\\Test User\\AppData\\Local', SystemRoot: 'C:\\Windows' }
|
|
|
|
function fakeSpawn(behaviour: (child: any) => void = child => child.emit('close', 0)) {
|
|
const state: any = { calls: [], stdin: [] as string[], killed: false }
|
|
|
|
const spawn = vi.fn((command: string, args: string[], options: any) => {
|
|
const child: any = new EventEmitter()
|
|
child.stdin = new EventEmitter()
|
|
|
|
child.stdin.end = (chunk: string) => {
|
|
state.stdin.push(chunk)
|
|
queueMicrotask(() => behaviour(child))
|
|
}
|
|
|
|
child.kill = () => {
|
|
state.killed = true
|
|
}
|
|
|
|
state.calls.push({ command, args, options })
|
|
|
|
return child
|
|
})
|
|
|
|
return { spawn, state }
|
|
}
|
|
|
|
it('finds the CLI only at the fixed install path', () => {
|
|
expect(resolveAiturkCliPath({ env, existsSync: p => p === CLI_PATH, platform: 'win32' })).toBe(CLI_PATH)
|
|
expect(resolveAiturkCliPath({ env, existsSync: () => false, platform: 'win32' })).toBeNull()
|
|
expect(resolveAiturkCliPath({ env: {}, existsSync: () => true, platform: 'win32' })).toBeNull()
|
|
expect(resolveAiturkCliPath({ env: { LOCALAPPDATA: 'relative' }, existsSync: () => true, platform: 'win32' })).toBeNull()
|
|
expect(resolveAiturkCliPath({ env, existsSync: () => true, platform: 'linux' })).toBeNull()
|
|
})
|
|
|
|
it('reports missing without spawning anything', async () => {
|
|
const { spawn } = fakeSpawn()
|
|
const run = createAiturkCliRunner({ spawn, existsSync: () => false, env, platform: 'win32' })
|
|
|
|
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe('missing')
|
|
expect(spawn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sends the key on stdin only, through an absolute cmd.exe, without a shell', async () => {
|
|
const { spawn, state } = fakeSpawn()
|
|
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
|
|
|
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(0)
|
|
|
|
const { command, args, options } = state.calls[0]
|
|
expect(command).toBe('C:\\Windows\\System32\\cmd.exe')
|
|
expect(args).toEqual(['/d', '/s', '/c', `""${CLI_PATH}" login --key-stdin --keep-other-account"`])
|
|
expect(options).toEqual({
|
|
shell: false,
|
|
windowsHide: true,
|
|
windowsVerbatimArguments: true,
|
|
stdio: ['pipe', 'ignore', 'ignore']
|
|
})
|
|
expect(leaks([command, args, options])).toBe(false)
|
|
expect(state.stdin).toEqual([`${CLI_KEY}\n`])
|
|
})
|
|
|
|
it('drops the keep flag for a confirmed overwrite', async () => {
|
|
const { spawn, state } = fakeSpawn()
|
|
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
|
|
|
await run(CLI_KEY, { keepOtherAccount: false })
|
|
expect(state.calls[0].args[3]).toBe(`""${CLI_PATH}" login --key-stdin"`)
|
|
})
|
|
|
|
it.each([0, 1, 2, 3, 4])('returns exit code %s', async code => {
|
|
const { spawn } = fakeSpawn(child => child.emit('close', code))
|
|
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
|
|
|
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(code)
|
|
})
|
|
|
|
it('returns null on spawn error, thrown spawn and stdin EPIPE', async () => {
|
|
const errored = fakeSpawn(child => child.emit('error', new Error('ENOENT')))
|
|
expect(
|
|
await createAiturkCliRunner({ spawn: errored.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
|
keepOtherAccount: true
|
|
})
|
|
).toBeNull()
|
|
|
|
const thrower = vi.fn(() => {
|
|
throw new Error('spawn EINVAL')
|
|
})
|
|
|
|
expect(
|
|
await createAiturkCliRunner({ spawn: thrower, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
|
keepOtherAccount: true
|
|
})
|
|
).toBeNull()
|
|
|
|
const epipe = fakeSpawn(child => {
|
|
child.stdin.emit('error', new Error('EPIPE'))
|
|
child.emit('close', 2)
|
|
})
|
|
|
|
expect(
|
|
await createAiturkCliRunner({ spawn: epipe.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
|
keepOtherAccount: true
|
|
})
|
|
).toBe(2)
|
|
})
|
|
|
|
it('kills a CLI that hangs past the timeout', async () => {
|
|
const { spawn, state } = fakeSpawn(() => {})
|
|
|
|
let fire = () => {}
|
|
|
|
const run = createAiturkCliRunner({
|
|
spawn,
|
|
existsSync: () => true,
|
|
env,
|
|
platform: 'win32',
|
|
setTimer: (fn, ms) => {
|
|
expect(ms).toBe(60_000)
|
|
fire = fn
|
|
|
|
return 1
|
|
},
|
|
clearTimer: () => {}
|
|
})
|
|
|
|
const pending = run(CLI_KEY, { keepOtherAccount: true })
|
|
fire()
|
|
|
|
expect(await pending).toBeNull()
|
|
expect(state.killed).toBe(true)
|
|
})
|
|
|
|
it('refuses an install path cmd.exe could reinterpret', async () => {
|
|
const { spawn } = fakeSpawn()
|
|
|
|
const run = createAiturkCliRunner({
|
|
spawn,
|
|
existsSync: () => true,
|
|
env: { LOCALAPPDATA: 'C:\\Users\\a%PATH%b\\AppData\\Local', SystemRoot: 'C:\\Windows' },
|
|
platform: 'win32'
|
|
})
|
|
|
|
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBeNull()
|
|
expect(spawn).not.toHaveBeenCalled()
|
|
})
|
|
})
|