cloudaxe-opencode/packages/opencode/test/plugin/azure.test.ts
2026-09-01 21:53:43 +02:00

280 lines
10 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test"
import { chmod } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { tmpdir } from "../fixture/fixture"
import type { Hooks } from "@opencode-ai/plugin"
import type { Auth, Provider } from "@opencode-ai/sdk/v2"
import { OAUTH_DUMMY_KEY } from "../../src/auth"
import { AzureAuthPlugin, createAzureAuthHooks } from "../../src/plugin/azure"
import { Process } from "../../src/util/process"
import { which } from "@opencode-ai/core/util/which"
const resourceName = process.env.AZURE_RESOURCE_NAME
const originalPath = process.env.PATH
afterEach(() => {
if (resourceName === undefined) delete process.env.AZURE_RESOURCE_NAME
else process.env.AZURE_RESOURCE_NAME = resourceName
if (originalPath === undefined) delete process.env.PATH
else process.env.PATH = originalPath
})
const oauth: Auth = {
type: "oauth",
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
expires: Date.now() + 60 * 60 * 1000,
accountId: "test-resource",
}
const provider: Provider = {
id: "azure",
name: "Azure",
source: "custom",
env: [],
options: {},
models: {},
}
function oauthMethod(hooks: Hooks) {
const method = hooks.auth?.methods.find((method) => method.type === "oauth")
if (!method || method.type !== "oauth") throw new Error("Azure OAuth method is missing")
return method
}
function loader(hooks: Hooks) {
if (!hooks.auth?.loader) throw new Error("Azure auth loader is missing")
return hooks.auth.loader
}
function customFetch(options: Record<string, unknown>) {
const result = options["fetch"]
if (typeof result !== "function") throw new Error("Azure custom fetch is missing")
return async (input: RequestInfo | URL, init?: RequestInit) => {
const response: unknown = await Reflect.apply(result, undefined, [input, init])
if (!(response instanceof Response)) throw new Error("Azure custom fetch returned an invalid response")
return response
}
}
function azureShell(scopes: string[]) {
return async (args: string[]) => {
const scope = args[args.indexOf("--scope") + 1]
scopes.push(scope)
return {
accessToken: `${scope}-token`,
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
}
}
}
async function azureCli(dir: string) {
const bin = path.join(dir, "azure cli")
const calls = path.join(dir, "calls.jsonl")
const script = path.join(bin, "cli.cjs")
await Bun.write(calls, "")
await Bun.write(
script,
`
const fs = require("node:fs")
const args = process.argv.slice(2)
fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + "\\n")
console.log(JSON.stringify(args.includes("get-access-token")
? { accessToken: "test-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }
: []))
`,
)
const executable = path.join(bin, process.platform === "win32" ? "az.cmd" : "az")
await Bun.write(
executable,
process.platform === "win32"
? `@"${process.execPath}" "${script}" %*\r\n`
: `#!/bin/sh\nexec '${process.execPath}' '${script}' "$@"\n`,
)
await chmod(executable, 0o755)
return {
bin,
calls: async () =>
(await Bun.file(calls).text())
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
}
}
describe("plugin.azure", () => {
test("initializes and runs Azure CLI under Node without Bun or a plugin shell", async () => {
await using tmp = await tmpdir()
const node = which("node")
if (!node) throw new Error("Node is required for the Azure runtime compatibility test")
const bundle = await Bun.build({
entrypoints: [path.join(import.meta.dir, "../../src/plugin/azure.ts")],
target: "node",
format: "esm",
})
expect(bundle.success).toBe(true)
const entry = path.join(tmp.path, "azure.mjs")
await Bun.write(entry, bundle.outputs[0])
const cli = await azureCli(tmp.path)
for (const installed of [false, true]) {
const result = await Process.run(
[
node,
"--input-type=module",
"-e",
`
import assert from "node:assert/strict"
import { AzureAuthPlugin } from ${JSON.stringify(pathToFileURL(entry).href)}
assert.equal(typeof Bun, "undefined")
delete process.env.AZURE_RESOURCE_NAME
const hooks = await AzureAuthPlugin({ $: undefined })
assert.equal(hooks.auth.provider, "azure")
assert.deepEqual(hooks.auth.methods.map((method) => method.type), ${JSON.stringify(installed ? ["api", "oauth"] : ["api"])})
if (${installed}) {
const method = hooks.auth.methods.find((method) => method.type === "oauth")
assert.equal(method.prompts[0].type, "text")
const authorization = await method.authorize({ resourceName: "test-resource" })
const auth = await authorization.callback()
assert.equal(auth.type, "success")
assert.equal(auth.accountId, "test-resource")
}
`,
],
{
env: { PATH: installed ? cli.bin : tmp.path, XDG_DATA_HOME: tmp.path },
nothrow: true,
},
)
expect(result.stderr.toString()).toBe("")
expect(result.code).toBe(0)
}
expect(await cli.calls()).toEqual([
["account", "get-access-token", "--scope", "https://cognitiveservices.azure.com/.default", "--output", "json"],
])
})
test("does not invoke Azure CLI during initialization", async () => {
await using tmp = await tmpdir()
const cli = await azureCli(tmp.path)
process.env.PATH = cli.bin
delete process.env.AZURE_RESOURCE_NAME
const hooks = await AzureAuthPlugin()
expect(await cli.calls()).toEqual([])
expect(oauthMethod(hooks).prompts?.[0].type).toBe("text")
})
test("keeps the existing API-key method and adds Entra ID", () => {
delete process.env.AZURE_RESOURCE_NAME
const hooks = createAzureAuthHooks(azureShell([]), fetch, true)
expect(hooks.auth?.provider).toBe("azure")
expect(hooks.auth?.methods.map((method) => [method.type, method.label])).toEqual([
["api", "API key"],
["oauth", "Microsoft Entra ID (Azure CLI)"],
])
expect(hooks.auth?.methods[0]).toEqual({
type: "api",
label: "API key",
prompts: [
{
type: "text",
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
},
],
})
expect(hooks.auth?.methods[1].prompts).toEqual(hooks.auth?.methods[0].prompts)
})
test("hides Azure CLI authentication when the Azure CLI is not installed", () => {
const hooks = createAzureAuthHooks(azureShell([]), fetch, false)
expect(hooks.auth?.methods.map((method) => method.type)).toEqual(["api"])
})
test("checks Azure CLI and stores the resource name", async () => {
const scopes: string[] = []
const hooks = createAzureAuthHooks(azureShell(scopes), fetch, true)
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
expect(await authorization.callback()).toMatchObject({
type: "success",
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
accountId: "test-resource",
})
expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default"])
})
test("supports Azure CLI versions that only provide expiresOn", async () => {
const hooks = createAzureAuthHooks(
async () => ({
accessToken: "legacy-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}),
fetch,
true,
)
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
expect(await authorization.callback()).toMatchObject({ type: "success", accountId: "test-resource" })
})
test("rejects Azure CLI tokens without a usable expiration", async () => {
const hooks = createAzureAuthHooks(async () => ({ accessToken: "invalid-token" }), fetch, true)
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
await expect(authorization.callback()).rejects.toThrow("Azure CLI returned an invalid token expiration")
})
test("does not change API-key loading", async () => {
const scopes: string[] = []
const hooks = createAzureAuthHooks(azureShell(scopes), fetch, true)
expect(await loader(hooks)(async () => ({ type: "api", key: "test-key" }), provider)).toEqual({})
expect(scopes).toEqual([])
})
test("uses Azure CLI bearer tokens for Azure inference endpoints", async () => {
const scopes: string[] = []
const requests: Headers[] = []
const hooks = createAzureAuthHooks(
azureShell(scopes),
async (_input, init) => {
requests.push(new Headers(init?.headers))
return new Response(null, { status: 200 })
},
true,
)
const options = await loader(hooks)(async () => oauth, provider)
const request = customFetch(options)
await request("https://test-resource.openai.azure.com/openai/v1/responses", {
headers: { "api-key": OAUTH_DUMMY_KEY, "x-keep": "yes" },
})
await request("https://test-resource.services.ai.azure.com/models/chat/completions", {
headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}` },
})
await request("https://test-resource.services.ai.azure.com/anthropic/v1/messages", {
headers: { "x-api-key": OAUTH_DUMMY_KEY },
})
expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default", "https://ai.azure.com/.default"])
expect(requests.map((headers) => headers.get("authorization"))).toEqual([
"Bearer https://cognitiveservices.azure.com/.default-token",
"Bearer https://cognitiveservices.azure.com/.default-token",
"Bearer https://ai.azure.com/.default-token",
])
expect(requests[0].get("api-key")).toBeNull()
expect(requests[0].get("x-keep")).toBe("yes")
expect(requests[2].get("x-api-key")).toBeNull()
expect(requests.every((headers) => headers.get("user-agent")?.startsWith("opencode/"))).toBe(true)
})
})