mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 11:25:15 +00:00
test(opencode): use local server for Codex refresh dedupe
This commit is contained in:
parent
fd2c3f3f13
commit
7d43db8772
2 changed files with 85 additions and 50 deletions
|
|
@ -118,6 +118,11 @@ interface TokenResponse {
|
|||
expires_in?: number
|
||||
}
|
||||
|
||||
interface CodexAuthPluginOptions {
|
||||
issuer?: string
|
||||
codexApiEndpoint?: string
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
||||
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
|
|
@ -136,8 +141,8 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk
|
|||
return response.json()
|
||||
}
|
||||
|
||||
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
||||
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||
async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promise<TokenResponse> {
|
||||
const response = await fetch(`${issuer}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
|
|
@ -364,7 +369,10 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
|
|||
})
|
||||
}
|
||||
|
||||
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
|
||||
const issuer = options.issuer ?? ISSUER
|
||||
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
|
||||
|
||||
return {
|
||||
provider: {
|
||||
id: "openai",
|
||||
|
|
@ -438,7 +446,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
||||
if (!refreshPromise) {
|
||||
log.info("refreshing codex access token")
|
||||
refreshPromise = refreshAccessToken(currentAuth.refresh)
|
||||
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
|
||||
.then(async (tokens) => {
|
||||
const accountId = extractAccountId(tokens) || authWithAccount.accountId
|
||||
await input.client.auth.set({
|
||||
|
|
@ -497,7 +505,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
|
||||
const url =
|
||||
parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
|
||||
? new URL(CODEX_API_ENDPOINT)
|
||||
? new URL(codexApiEndpoint)
|
||||
: parsed
|
||||
|
||||
return fetch(url, {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
CodexAuthPlugin,
|
||||
parseJwtClaims,
|
||||
|
|
@ -7,12 +7,6 @@ import {
|
|||
type IdTokenClaims,
|
||||
} from "../../src/plugin/codex"
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
function createTestJwt(payload: object): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
|
||||
|
|
@ -135,68 +129,101 @@ describe("plugin.codex", () => {
|
|||
access: "",
|
||||
expires: 0,
|
||||
}
|
||||
const authUpdates: unknown[] = []
|
||||
const authUpdates: Array<{
|
||||
body: { refresh: string; access: string; expires: number; accountId?: string }
|
||||
}> = []
|
||||
let resolveRefresh: (() => void) | undefined
|
||||
const refreshReady = new Promise<void>((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})
|
||||
let refreshRequests = 0
|
||||
const apiRequests: { authorization: string | null; accountId: string | null }[] = []
|
||||
|
||||
globalThis.fetch = mock(async (request: RequestInfo | URL) => {
|
||||
const url = request instanceof URL ? request.href : typeof request === "string" ? request : request.url
|
||||
if (url === "https://auth.openai.com/oauth/token") {
|
||||
refreshRequests += 1
|
||||
await refreshReady
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/oauth/token") {
|
||||
expect(await request.text()).toContain("refresh_token=refresh-old")
|
||||
refreshRequests += 1
|
||||
await refreshReady
|
||||
return Response.json({
|
||||
id_token: createTestJwt({ chatgpt_account_id: "acc-123" }),
|
||||
access_token: "access-new",
|
||||
refresh_token: "refresh-new",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return new Response("{}", { status: 200 })
|
||||
}) as unknown as typeof fetch
|
||||
if (url.pathname === "/backend-api/codex/responses") {
|
||||
apiRequests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
accountId: request.headers.get("ChatGPT-Account-Id"),
|
||||
})
|
||||
return new Response("{}", { status: 200 })
|
||||
}
|
||||
|
||||
const hooks = await CodexAuthPlugin({
|
||||
client: {
|
||||
auth: {
|
||||
async set(input: { body: { refresh: string; access: string; expires: number } }) {
|
||||
authUpdates.push(input)
|
||||
auth = {
|
||||
type: "oauth",
|
||||
refresh: input.body.refresh,
|
||||
access: input.body.access,
|
||||
expires: input.body.expires,
|
||||
}
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
project: {} as never,
|
||||
directory: "",
|
||||
worktree: "",
|
||||
experimental_workspace: {
|
||||
register() {},
|
||||
return new Response("unexpected request", { status: 500 })
|
||||
},
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: {} as never,
|
||||
})
|
||||
|
||||
const hooks = await CodexAuthPlugin(
|
||||
{
|
||||
client: {
|
||||
auth: {
|
||||
async set(input: { body: { refresh: string; access: string; expires: number; accountId?: string } }) {
|
||||
authUpdates.push(input)
|
||||
auth = {
|
||||
type: "oauth",
|
||||
refresh: input.body.refresh,
|
||||
access: input.body.access,
|
||||
expires: input.body.expires,
|
||||
...(input.body.accountId && { accountId: input.body.accountId }),
|
||||
}
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
project: {} as never,
|
||||
directory: "",
|
||||
worktree: "",
|
||||
experimental_workspace: {
|
||||
register() {},
|
||||
},
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: {} as never,
|
||||
},
|
||||
{
|
||||
issuer: server.url.origin,
|
||||
codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(),
|
||||
},
|
||||
)
|
||||
const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never)
|
||||
|
||||
const first = loaded.fetch!("https://api.openai.com/v1/responses")
|
||||
const second = loaded.fetch!("https://api.openai.com/v1/responses")
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(refreshRequests).toBe(1)
|
||||
await waitFor(() => refreshRequests === 1)
|
||||
expect(apiRequests).toHaveLength(0)
|
||||
|
||||
resolveRefresh!()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(refreshRequests).toBe(1)
|
||||
expect(authUpdates).toHaveLength(1)
|
||||
expect(authUpdates[0]?.body.refresh).toBe("refresh-new")
|
||||
expect(authUpdates[0]?.body.access).toBe("access-new")
|
||||
expect(authUpdates[0]?.body.accountId).toBe("acc-123")
|
||||
expect(apiRequests).toEqual([
|
||||
{ authorization: "Bearer access-new", accountId: "acc-123" },
|
||||
{ authorization: "Bearer access-new", accountId: "acc-123" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
async function waitFor(predicate: () => boolean) {
|
||||
const started = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - started > 1_000) throw new Error("timed out waiting for condition")
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue