import { describe, expect, test } from "bun:test" import { Deferred, Effect, Logger, Schedule } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http" import { TestClock } from "effect/testing" import { Credential } from "@opencode/core/credential" import { Integration } from "@opencode/core/integration" import { Model } from "@opencode/core/model" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { Provider } from "@opencode/core/provider" import { ProviderPlugins } from "@opencode/core/plugin/provider" import { DigitalOceanPlugin } from "@opencode/core/plugin/provider/digitalocean" import { drain } from "../lib/clock" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" const it = testEffect(PluginTestLayer) const providerID = Provider.ID.make("digitalocean") const integrationID = Integration.ID.make("digitalocean") const methodID = Integration.MethodID.make("browser") const addPlugin = Effect.fn(function* () { const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin, "test") yield* DigitalOceanPlugin.effect(host) }) const oauthCredential = Effect.fn(function* (access = "do-token", expires = 0) { const credentials = yield* Credential.Service return yield* credentials.create({ integrationID, value: Credential.OAuth.make({ type: "oauth", methodID, access, refresh: "", expires }), }) }) const discovery = Effect.gen(function* () { const providers = yield* Provider.Service const models = yield* Model.Service yield* providers.transform((draft) => { draft.update(providerID, (provider) => { provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://inference.do-ai.run/v1" } }) draft.models.update(providerID, Model.ID.make("snapshot-model"), () => {}) draft.models.update(providerID, Model.ID.make("router:configured"), (model) => { model.name = "Configured router" model.limit.context = 256_000 }) draft.models.update(Provider.ID.openai, Model.ID.make("router:alpha"), () => {}) }) const remote: { status: number body: unknown requests: HttpClientRequest.HttpClientRequest[] gate?: Deferred.Deferred } = { status: 200, body: { model_routers: [{ name: "alpha" }, { name: "configured" }] }, requests: [], } const http = HttpClient.make((request) => Effect.gen(function* () { remote.requests.push(request) if (remote.gate) yield* Deferred.await(remote.gate) return HttpClientResponse.fromWeb(request, Response.json(remote.body, { status: remote.status })) }), ) return { models, remote, install: addPlugin().pipe(Effect.provideService(HttpClient.HttpClient, http)) } }) const status = Effect.fn(function* (attempt: Integration.Attempt) { const integrations = yield* Integration.Service return yield* integrations.oauth .status({ integrationID, attemptID: attempt.attemptID }) .pipe( Effect.repeat({ until: (value) => value.status !== "pending", schedule: Schedule.spaced("10 millis") }), Effect.timeout("3 seconds"), ) }) describe("DigitalOceanPlugin", () => { test("is registered alongside the other provider plugins", () => { expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.digitalocean") }) it.effect("registers browser OAuth alongside generic key and environment methods", () => Effect.gen(function* () { const integrations = yield* Integration.Service yield* integrations.transform((draft) => { draft.method.update({ integrationID, method: { type: "key" } }) draft.method.update({ integrationID, method: { type: "env", names: ["DIGITALOCEAN_ACCESS_TOKEN"] } }) }) yield* addPlugin() expect((yield* integrations.get(integrationID))?.methods).toEqual([ { type: "key" }, { type: "env", names: ["DIGITALOCEAN_ACCESS_TOKEN"] }, { id: methodID, type: "oauth", label: "Login with DigitalOcean" }, ]) }), ) it.effect("loads routers for the signed-in account", () => Effect.gen(function* () { const fixture = yield* discovery yield* oauthCredential() yield* fixture.install yield* drain expect(fixture.remote.requests).toHaveLength(1) expect(fixture.remote.requests[0]).toMatchObject({ method: "GET", url: "https://api.digitalocean.com/v2/gen-ai/models/routers", headers: { authorization: "Bearer do-token", accept: "application/json", "user-agent": expect.any(String) }, }) expect(yield* fixture.models.get(providerID, Model.ID.make("router:alpha"))).toMatchObject({ name: "alpha", family: "digitalocean-inference-routers", package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://inference.do-ai.run/v1" }, capabilities: { tools: true, input: ["text"], output: ["text"] }, limit: { context: 128_000, output: 8_192 }, }) expect(yield* fixture.models.get(providerID, Model.ID.make("router:configured"))).toMatchObject({ name: "Configured router", limit: { context: 256_000 }, }) }), ) it.effect("loads nothing and logs nothing at startup when the access token has expired", () => Effect.gen(function* () { const fixture = yield* discovery const warnings: unknown[] = [] yield* TestClock.adjust("1 second") yield* oauthCredential("do-token", 1) yield* fixture.install.pipe( Effect.provide( Logger.layer([ Logger.make((entry) => { if (entry.logLevel === "Warn" || entry.logLevel === "Error") warnings.push(entry.message) }), ]), ), ) yield* drain expect(fixture.remote.requests).toHaveLength(0) expect(yield* fixture.models.get(providerID, Model.ID.make("router:alpha"))).toBeUndefined() expect(warnings).toEqual([]) }), ) it.effect("refreshes routers in the background with the latest access token", () => Effect.gen(function* () { const fixture = yield* discovery const credentials = yield* Credential.Service const credential = yield* oauthCredential() yield* fixture.install yield* drain expect(fixture.remote.requests).toHaveLength(1) yield* credentials.update(credential.id, { value: Credential.OAuth.make({ type: "oauth", methodID, access: "rotated-token", refresh: "", expires: 0 }), }) yield* TestClock.adjust("10 minutes") yield* drain expect(fixture.remote.requests).toHaveLength(2) expect(fixture.remote.requests[1]?.headers.authorization).toBe("Bearer rotated-token") }), ) it.effect("loads on credential changes and clears the previous account's routers", () => Effect.gen(function* () { const fixture = yield* discovery const credentials = yield* Credential.Service yield* fixture.install yield* drain expect(fixture.remote.requests).toHaveLength(0) yield* oauthCredential() yield* drain expect(yield* fixture.models.get(providerID, Model.ID.make("router:alpha"))).toBeDefined() fixture.remote.status = 401 const second = yield* oauthCredential("other-account") yield* drain expect(fixture.remote.requests.at(-1)?.headers.authorization).toBe("Bearer other-account") expect(yield* fixture.models.get(providerID, Model.ID.make("router:alpha"))).toBeUndefined() fixture.remote.status = 200 fixture.remote.body = { model_routers: [{ name: "beta" }] } const count = fixture.remote.requests.length yield* credentials.create({ integrationID, value: Credential.Key.make({ type: "key", key: "do-key" }) }) yield* drain expect(fixture.remote.requests).toHaveLength(count) expect(yield* fixture.models.get(providerID, Model.ID.make("router:beta"))).toBeUndefined() yield* credentials.activate(second.id) yield* drain expect(yield* fixture.models.get(providerID, Model.ID.make("router:beta"))).toBeDefined() yield* credentials.remove(second.id) yield* drain expect(yield* fixture.models.get(providerID, Model.ID.make("router:beta"))).toBeUndefined() }), ) it.effect("shows a returning account's cached routers at once and refreshes them in the background", () => Effect.gen(function* () { const fixture = yield* discovery const credentials = yield* Credential.Service const first = yield* oauthCredential() yield* fixture.install yield* drain fixture.remote.body = { model_routers: [{ name: "beta" }] } yield* oauthCredential("other-account") yield* drain const router = (name: string) => fixture.models.get(providerID, Model.ID.make(`router:${name}`)) expect(yield* router("beta")).toBeDefined() fixture.remote.body = { model_routers: [{ name: "gamma" }] } fixture.remote.gate = yield* Deferred.make() const count = fixture.remote.requests.length yield* credentials.activate(first.id) yield* drain expect(yield* router("alpha")).toBeDefined() expect(fixture.remote.requests).toHaveLength(count + 1) yield* Deferred.succeed(fixture.remote.gate, undefined) yield* drain expect(yield* router("alpha")).toBeUndefined() expect(yield* router("gamma")).toBeDefined() expect(fixture.remote.requests).toHaveLength(count + 1) }), ) it.live("completes browser OAuth independently of router discovery and releases the listener", () => Effect.gen(function* () { const fixture = yield* discovery fixture.remote.status = 503 yield* fixture.install const integrations = yield* Integration.Service const credentials = yield* Credential.Service const attempt = yield* integrations.oauth.connect({ integrationID, methodID }) const url = new URL(attempt.url) expect(attempt.mode).toBe("auto") expect(url.origin + url.pathname).toBe("https://cloud.digitalocean.com/v1/oauth/authorize") expect(url.searchParams.get("response_type")).toBe("token") expect(url.searchParams.get("client_id")).toBe("b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82") expect(url.searchParams.get("scope")).toBe("genai:read inference:query") expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:1456/auth/callback") expect(url.searchParams.get("state")).toBeTruthy() const page = yield* Effect.promise(() => fetch("http://localhost:1456/auth/callback", { headers: { Connection: "close" } }).then((res) => res.text()), ) expect(page).toContain("/auth/token") const now = Date.now() const response = yield* Effect.promise(() => fetch("http://localhost:1456/auth/token", { method: "POST", headers: { "Content-Type": "application/json", Connection: "close" }, body: JSON.stringify({ access_token: "do-token", expires_in: "3600", state: url.searchParams.get("state") }), }), ) expect(response.status).toBe(200) expect(yield* status(attempt)).toMatchObject({ status: "complete" }) const saved = (yield* credentials.list(integrationID))[0]?.value expect(saved).toMatchObject({ type: "oauth", methodID, access: "do-token", refresh: "" }) expect(saved?.metadata).toBeUndefined() if (saved?.type !== "oauth") throw new Error("Expected OAuth credential") expect(saved.expires).toBeGreaterThanOrEqual(now + 3_600_000) expect(saved.expires).toBeLessThanOrEqual(Date.now() + 3_600_000) const next = yield* integrations.oauth.connect({ integrationID, methodID }) expect(new URL(next.url).searchParams.get("state")).not.toBe(url.searchParams.get("state")) yield* integrations.oauth.cancel({ integrationID, attemptID: next.attemptID }) const retry = yield* integrations.oauth.connect({ integrationID, methodID }) yield* integrations.oauth.cancel({ integrationID, attemptID: retry.attemptID }) }), ) const invalid = [ { name: "mismatched state", body: JSON.stringify({ access_token: "token", state: "wrong" }), message: "Invalid OAuth state", }, { name: "missing token", body: JSON.stringify({}), message: "Missing access token" }, { name: "malformed JSON", body: "not-json", message: "Missing access token" }, { name: "provider denial", body: JSON.stringify({ error: "access_denied", error_description: "Denied" }), message: "Denied", }, { name: "provider denial without a description", body: JSON.stringify({ error: "access_denied", error_description: "" }), message: "access_denied", }, ] invalid.forEach((fixture) => { it.live(`rejects ${fixture.name} and releases the listener`, () => Effect.gen(function* () { yield* addPlugin() const integrations = yield* Integration.Service const credentials = yield* Credential.Service const attempt = yield* integrations.oauth.connect({ integrationID, methodID }) const response = yield* Effect.promise(() => fetch("http://localhost:1456/auth/token", { method: "POST", headers: { "Content-Type": "application/json", Connection: "close" }, body: fixture.body, }), ) expect(response.status).toBe(400) expect(yield* status(attempt)).toMatchObject({ status: "failed", message: fixture.message }) expect(yield* credentials.list(integrationID)).toEqual([]) const next = yield* integrations.oauth.connect({ integrationID, methodID }) yield* integrations.oauth.cancel({ integrationID, attemptID: next.attemptID }) }), ) }) })