* feat: improve GitHub provider onboarding and lifecycle * fix: address copilot review in provider manager * fix: address follow-up copilot review comments * test: resolve rebase conflict in provider profiles suite * fix: clear stale github hydrated marker * fix: harden github onboarding auth precedence * fix: remove merge markers from provider tests * fix: resolve latest copilot onboarding comments --------- Co-authored-by: KRATOS <84986124+gnanam1990@users.noreply.github.com>
171 lines
4.5 KiB
TypeScript
171 lines
4.5 KiB
TypeScript
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
|
|
|
import {
|
|
DEFAULT_GITHUB_DEVICE_SCOPE,
|
|
GitHubDeviceFlowError,
|
|
pollAccessToken,
|
|
requestDeviceCode,
|
|
} from './deviceFlow.js'
|
|
|
|
describe('requestDeviceCode', () => {
|
|
const originalFetch = globalThis.fetch
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch
|
|
})
|
|
|
|
test('parses successful device code response', async () => {
|
|
globalThis.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
device_code: 'abc',
|
|
user_code: 'ABCD-1234',
|
|
verification_uri: 'https://github.com/login/device',
|
|
expires_in: 600,
|
|
interval: 5,
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
)
|
|
|
|
const r = await requestDeviceCode({
|
|
clientId: 'test-client',
|
|
fetchImpl: globalThis.fetch,
|
|
})
|
|
expect(r.device_code).toBe('abc')
|
|
expect(r.user_code).toBe('ABCD-1234')
|
|
expect(r.verification_uri).toBe('https://github.com/login/device')
|
|
expect(r.expires_in).toBe(600)
|
|
expect(r.interval).toBe(5)
|
|
})
|
|
|
|
test('throws on HTTP error', async () => {
|
|
globalThis.fetch = mock(() =>
|
|
Promise.resolve(new Response('bad', { status: 500 })),
|
|
)
|
|
await expect(
|
|
requestDeviceCode({ clientId: 'x', fetchImpl: globalThis.fetch }),
|
|
).rejects.toThrow(GitHubDeviceFlowError)
|
|
})
|
|
|
|
test('uses OAuth-safe default scope', async () => {
|
|
let capturedScope = ''
|
|
globalThis.fetch = mock((_url: RequestInfo | URL, init?: RequestInit) => {
|
|
const body = init?.body
|
|
if (body instanceof URLSearchParams) {
|
|
capturedScope = body.get('scope') ?? ''
|
|
} else {
|
|
capturedScope = new URLSearchParams(String(body ?? '')).get('scope') ?? ''
|
|
}
|
|
|
|
return Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
device_code: 'abc',
|
|
user_code: 'ABCD-1234',
|
|
verification_uri: 'https://github.com/login/device',
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
)
|
|
})
|
|
|
|
await requestDeviceCode({ clientId: 'test-client', fetchImpl: globalThis.fetch })
|
|
expect(capturedScope).toBe(DEFAULT_GITHUB_DEVICE_SCOPE)
|
|
expect(capturedScope).toBe('read:user')
|
|
})
|
|
|
|
test('retries with OAuth-safe scope on invalid_scope', async () => {
|
|
const scopesSeen: string[] = []
|
|
let callCount = 0
|
|
|
|
globalThis.fetch = mock((_url: RequestInfo | URL, init?: RequestInit) => {
|
|
const body = init?.body
|
|
const scope =
|
|
body instanceof URLSearchParams
|
|
? body.get('scope') ?? ''
|
|
: new URLSearchParams(String(body ?? '')).get('scope') ?? ''
|
|
scopesSeen.push(scope)
|
|
callCount++
|
|
|
|
if (callCount === 1) {
|
|
return Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
error: 'invalid_scope',
|
|
error_description: 'invalid models scope',
|
|
}),
|
|
{ status: 400 },
|
|
),
|
|
)
|
|
}
|
|
|
|
return Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
device_code: 'abc',
|
|
user_code: 'ABCD-1234',
|
|
verification_uri: 'https://github.com/login/device',
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
)
|
|
})
|
|
|
|
const result = await requestDeviceCode({
|
|
clientId: 'test-client',
|
|
scope: 'read:user,models:read',
|
|
fetchImpl: globalThis.fetch,
|
|
})
|
|
|
|
expect(result.device_code).toBe('abc')
|
|
expect(callCount).toBe(2)
|
|
expect(scopesSeen).toEqual(['read:user,models:read', 'read:user'])
|
|
})
|
|
})
|
|
|
|
describe('pollAccessToken', () => {
|
|
const originalFetch = globalThis.fetch
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch
|
|
})
|
|
|
|
test('returns token when GitHub responds with access_token immediately', async () => {
|
|
let calls = 0
|
|
globalThis.fetch = mock(() => {
|
|
calls++
|
|
return Promise.resolve(
|
|
new Response(JSON.stringify({ access_token: 'tok-xyz' }), {
|
|
status: 200,
|
|
}),
|
|
)
|
|
})
|
|
|
|
const token = await pollAccessToken('dev-code', {
|
|
clientId: 'cid',
|
|
fetchImpl: globalThis.fetch,
|
|
})
|
|
expect(token).toBe('tok-xyz')
|
|
expect(calls).toBe(1)
|
|
})
|
|
|
|
test('throws on access_denied', async () => {
|
|
globalThis.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ error: 'access_denied' }), {
|
|
status: 200,
|
|
}),
|
|
),
|
|
)
|
|
await expect(
|
|
pollAccessToken('dc', {
|
|
clientId: 'c',
|
|
fetchImpl: globalThis.fetch,
|
|
}),
|
|
).rejects.toThrow(/denied/)
|
|
})
|
|
})
|