278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import path from "node:path"
|
|
import { OPENCODE_VERSION } from "../src/version"
|
|
|
|
describe("auth command", () => {
|
|
test("registers authentication commands", async () => {
|
|
const [auth, list, login, logout] = await Promise.all([
|
|
cli(["auth", "--help"]),
|
|
cli(["auth", "list", "--help"]),
|
|
cli(["auth", "login", "--help"]),
|
|
cli(["auth", "logout", "--help"]),
|
|
])
|
|
|
|
expect(auth.exitCode).toBe(0)
|
|
expect(auth.stdout).toContain("list")
|
|
expect(auth.stdout).toContain("login")
|
|
expect(auth.stdout).toContain("logout")
|
|
expect(auth.stdout).toContain("manage AI providers and credentials")
|
|
expect(auth.stdout).toContain("list providers and credentials")
|
|
expect(auth.stdout).toContain("log in to a provider")
|
|
expect(auth.stdout).toContain("log out from a configured provider")
|
|
expect(auth.stdout).not.toContain("connect")
|
|
expect(list.exitCode).toBe(0)
|
|
expect(list.stdout).toContain("opencode auth list [flags]")
|
|
expect(list.stdout).toContain("--format")
|
|
expect(login.exitCode).toBe(0)
|
|
expect(login.stdout).toContain("opencode auth login [flags] [<target>]")
|
|
expect(login.stdout).toContain("Integration ID, name, or well-known provider URL")
|
|
expect(login.stdout).toContain("--method")
|
|
expect(logout.exitCode).toBe(0)
|
|
expect(logout.stdout).toContain("opencode auth logout [flags] [<target>]")
|
|
})
|
|
|
|
test("lists stored and environment connections", async () => {
|
|
const requests: string[] = []
|
|
using server = authServer((request, url) => {
|
|
if (url.pathname === "/api/integration") {
|
|
return Response.json(
|
|
located([
|
|
{
|
|
id: "anthropic",
|
|
name: "Anthropic",
|
|
methods: [],
|
|
connections: [
|
|
{ type: "credential", id: "cred_test", label: "default" },
|
|
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
|
],
|
|
},
|
|
{ id: "openai", name: "OpenAI", methods: [], connections: [] },
|
|
]),
|
|
)
|
|
}
|
|
return new Response("Not found", { status: 404 })
|
|
}, requests)
|
|
|
|
const result = await cli(["auth", "list", "--format", "json", "--server", server.url.toString()])
|
|
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
|
expect(JSON.parse(result.stdout)).toEqual([
|
|
{
|
|
id: "anthropic",
|
|
name: "Anthropic",
|
|
connections: [
|
|
{ type: "credential", id: "cred_test", label: "default" },
|
|
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
|
],
|
|
},
|
|
])
|
|
expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration"))
|
|
})
|
|
|
|
test("runs command authentication without interactive input", async () => {
|
|
const requests: Array<{ method: string; path: string }> = []
|
|
using server = authServer((request, url) => {
|
|
requests.push({ method: request.method, path: url.pathname })
|
|
if (url.pathname === "/api/integration") {
|
|
return Response.json(
|
|
located([
|
|
{
|
|
id: "company",
|
|
name: "Company",
|
|
methods: [{ id: "login", type: "command", label: "Company login", command: ["company", "login"] }],
|
|
connections: [],
|
|
},
|
|
]),
|
|
)
|
|
}
|
|
if (url.pathname === "/api/integration/company/connect/command" && request.method === "POST") {
|
|
return Response.json(located({ attemptID: "con_test", time: { created: 1, expires: 2 } }))
|
|
}
|
|
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "GET") {
|
|
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
|
|
}
|
|
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "DELETE") {
|
|
return new Response(null, { status: 204 })
|
|
}
|
|
return new Response("Not found", { status: 404 })
|
|
})
|
|
|
|
const result = await cli(["auth", "login", "company", "--server", server.url.toString()])
|
|
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
|
expect(result.stdout).toContain("Connected to Company")
|
|
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/company/connect/command" })
|
|
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/company/connect/command/con_test" })
|
|
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/company/connect/command/con_test" })
|
|
})
|
|
|
|
test("completes automatic OAuth authentication", async () => {
|
|
const requests: Array<{ method: string; path: string }> = []
|
|
using server = authServer((request, url) => {
|
|
requests.push({ method: request.method, path: url.pathname })
|
|
if (url.pathname === "/api/integration") {
|
|
return Response.json(
|
|
located([
|
|
{
|
|
id: "openai",
|
|
name: "OpenAI",
|
|
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
|
|
connections: [],
|
|
},
|
|
]),
|
|
)
|
|
}
|
|
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
|
|
return Response.json(
|
|
located({
|
|
attemptID: "con_oauth",
|
|
url: "https://example.com/authorize",
|
|
instructions: "Authorize OpenAI",
|
|
mode: "auto",
|
|
time: { created: 1, expires: 2 },
|
|
}),
|
|
)
|
|
}
|
|
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
|
|
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
|
|
}
|
|
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
|
|
return new Response(null, { status: 204 })
|
|
}
|
|
return new Response("Not found", { status: 404 })
|
|
})
|
|
|
|
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
|
|
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
|
expect(result.stdout).toContain("https://example.com/authorize")
|
|
expect(result.stdout).toContain("Connected to OpenAI")
|
|
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/openai/connect/oauth" })
|
|
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/openai/connect/oauth/con_oauth" })
|
|
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/openai/connect/oauth/con_oauth" })
|
|
})
|
|
|
|
test("settles the OAuth spinner when status polling fails", async () => {
|
|
using server = authServer((request, url) => {
|
|
if (url.pathname === "/api/integration") {
|
|
return Response.json(
|
|
located([
|
|
{
|
|
id: "openai",
|
|
name: "OpenAI",
|
|
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
|
|
connections: [],
|
|
},
|
|
]),
|
|
)
|
|
}
|
|
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
|
|
return Response.json(
|
|
located({
|
|
attemptID: "con_oauth",
|
|
url: "https://example.com/authorize",
|
|
instructions: "Authorize OpenAI",
|
|
mode: "auto",
|
|
time: { created: 1, expires: 2 },
|
|
}),
|
|
)
|
|
}
|
|
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
|
|
return new Response("Unavailable", { status: 500 })
|
|
}
|
|
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
|
|
return new Response(null, { status: 204 })
|
|
}
|
|
return new Response("Not found", { status: 404 })
|
|
})
|
|
|
|
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
|
|
expect(result.exitCode).toBe(1)
|
|
expect(result.stdout).toContain("Authentication failed")
|
|
expect(result.stdout).toContain("Failed")
|
|
expect(result.stdout).not.toContain("\n at ")
|
|
})
|
|
|
|
test("removes stored credentials", async () => {
|
|
const removed: string[] = []
|
|
using server = authServer((request, url) => {
|
|
if (url.pathname === "/api/integration") {
|
|
return Response.json(
|
|
located([
|
|
{
|
|
id: "anthropic",
|
|
name: "Anthropic",
|
|
methods: [{ type: "key" }],
|
|
connections: [{ type: "credential", id: "cred_test", label: "default" }],
|
|
},
|
|
]),
|
|
)
|
|
}
|
|
if (url.pathname === "/api/credential/cred_test" && request.method === "DELETE") {
|
|
removed.push("cred_test")
|
|
return new Response(null, { status: 204 })
|
|
}
|
|
return new Response("Not found", { status: 404 })
|
|
})
|
|
|
|
const result = await cli(["auth", "logout", "anthropic", "--server", server.url.toString()])
|
|
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
|
expect(result.stdout).toContain("Disconnected from Anthropic")
|
|
expect(removed).toEqual(["cred_test"])
|
|
})
|
|
|
|
test("requires a target outside an interactive terminal", async () => {
|
|
const result = await cli(["auth", "login"])
|
|
expect(result.exitCode).toBe(1)
|
|
expect(result.stdout).toContain("Pass an integration ID or name")
|
|
expect(result.stdout).not.toContain("Background service failed to start")
|
|
})
|
|
|
|
test("reports list connection failures without a stack trace", async () => {
|
|
using server = Bun.serve({ port: 0, fetch: () => new Response("Unavailable", { status: 503 }) })
|
|
const result = await cli(["auth", "list", "--server", server.url.toString()])
|
|
expect(result.exitCode).toBe(1)
|
|
expect(result.stdout).toBe("")
|
|
expect(result.stderr).toContain("did not provide a compatible V2 health response")
|
|
expect(result.stderr).not.toContain("\n at ")
|
|
})
|
|
})
|
|
|
|
function authServer(fetch: (request: Request, url: URL) => Response | Promise<Response>, requests?: string[]) {
|
|
return Bun.serve({
|
|
port: 0,
|
|
fetch(request) {
|
|
const url = new URL(request.url)
|
|
requests?.push(url.pathname)
|
|
if (url.pathname === "/api/health") return health()
|
|
if (url.pathname === "/api/model/default") return Response.json(located(null))
|
|
return fetch(request, url)
|
|
},
|
|
})
|
|
}
|
|
|
|
function health() {
|
|
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
|
}
|
|
|
|
function located<T>(data: T) {
|
|
return {
|
|
location: {
|
|
directory: process.cwd(),
|
|
project: { id: "project", directory: process.cwd(), canonical: process.cwd() },
|
|
},
|
|
data,
|
|
}
|
|
}
|
|
|
|
async function cli(args: string[]) {
|
|
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
|
cwd: path.join(import.meta.dir, ".."),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
})
|
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
new Response(child.stdout).text(),
|
|
new Response(child.stderr).text(),
|
|
child.exited,
|
|
])
|
|
return { stdout, stderr, exitCode }
|
|
}
|