Files
opencode/packages/core/test/integration.test.ts
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 bc70651f62 fix(core): dedupe credential refreshes
2026-06-26 19:59:08 +00:00

462 lines
17 KiB
TypeScript

import { describe, expect } from "bun:test"
import { Deferred, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { testEffect } from "./lib/effect"
const it = testEffect(
Integration.locationLayer.pipe(Layer.provideMerge(Credential.defaultLayer), Layer.provideMerge(EventV2.defaultLayer)),
)
describe("Integration", () => {
it.effect("registers integrations through the editor", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const scope = yield* Scope.fork(yield* Scope.Scope)
const openai = Integration.ID.make("openai")
yield* integrations
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
.pipe(Scope.provide(scope))
expect(yield* integrations.get(openai)).toEqual(
new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
)
yield* Scope.close(scope, Exit.void)
expect(yield* integrations.get(openai)).toBeUndefined()
}),
)
it.effect("reveals the previous registration when an override closes", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const id = Integration.ID.make("openai")
const first = yield* Scope.fork(yield* Scope.Scope)
const second = yield* Scope.fork(yield* Scope.Scope)
yield* integrations
.transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI")))
.pipe(Scope.provide(first))
yield* integrations
.transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override")))
.pipe(Scope.provide(second))
expect((yield* integrations.get(id))?.name).toBe("OpenAI Override")
yield* Scope.close(second, Exit.void)
expect((yield* integrations.get(id))?.name).toBe("OpenAI")
expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
}),
)
it.effect("registers and overrides methods independently", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
const first = yield* Scope.fork(yield* Scope.Scope)
const second = yield* Scope.fork(yield* Scope.Scope)
const authorize = () =>
Effect.succeed({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.never,
})
yield* integrations
.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize,
}),
)
.pipe(Scope.provide(first))
yield* integrations
.transform((editor) => {
expect(editor.get(integrationID)).toEqual({ id: integrationID, name: "openai" })
expect(editor.list()).toEqual([{ id: integrationID, name: "openai" }])
expect(editor.method.list(integrationID)).toEqual([
expect.objectContaining({ id: methodID, label: "ChatGPT" }),
])
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
authorize,
})
})
.pipe(Scope.provide(second))
expect((yield* integrations.get(integrationID))?.name).toBe("openai")
expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT Override" })
yield* Scope.close(second, Exit.void)
expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })])
}),
)
it.effect("connects with a key and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const events = yield* EventV2.Service
const integrationID = Integration.ID.make("openai")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { type: "key", label: "API key" },
}),
)
const updated = yield* events
.subscribe(Integration.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* integrations.connection.key({
integrationID,
key: "secret",
label: "Work",
})
expect(yield* credentials.list(integrationID)).toEqual([
expect.objectContaining({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "secret" }),
}),
])
expect((yield* Fiber.join(updated)).length).toBe(1)
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: (code: string) =>
Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: "access",
refresh: "refresh",
expires: 1,
metadata: { code },
}),
),
}),
}),
)
const attempt = yield* integrations.connection.oauth({
integrationID,
methodID,
inputs: {},
label: "Personal",
})
expect(attempt.mode).toBe("code")
yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" })
expect((yield* credentials.list(integrationID))[0]).toEqual(
expect.objectContaining({
integrationID,
label: "Personal",
value: Credential.OAuth.make({
type: "oauth",
methodID,
access: "access",
refresh: "refresh",
expires: 1,
metadata: { code: "1234" },
}),
}),
)
}),
)
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
let closed = false
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: () => Effect.die("unexpected callback"),
}),
),
}),
)
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
Integration.CodeRequiredError,
)
expect(closed).toBe(false)
yield* integrations.attempt.cancel(attempt.attemptID)
expect(closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
it.effect("completes auto OAuth in the background", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("browser")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.succeed({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.succeed(
Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }),
),
}),
}),
)
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
yield* Effect.yieldNow
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
status: "complete",
time: attempt.time,
})
expect(yield* credentials.list(integrationID)).toHaveLength(1)
}),
)
it.effect("expires abandoned OAuth attempts", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("browser")
let closed = false
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.never,
}),
),
}),
)
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
yield* TestClock.adjust(Duration.minutes(10))
yield* Effect.yieldNow
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
status: "expired",
time: attempt.time,
})
expect(closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
it.effect("projects credential and env connections", () => {
const integrationID = Integration.ID.make("acme")
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.INTEGRATION_TEST_ACME_KEY
process.env.INTEGRATION_TEST_ACME_KEY = "secret"
delete process.env.INTEGRATION_TEST_ACME_MISSING
return previous
}),
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: {
type: "env",
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
},
}),
)
const work = yield* credentials.create({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "a" }),
})
const personal = yield* credentials.create({
integrationID,
label: "Personal",
value: Credential.Key.make({ type: "key", key: "b" }),
})
// Stored credentials and detected env vars appear as connections.
expect((yield* integrations.get(integrationID))?.connections).toEqual([
{
type: "credential",
id: personal.id,
label: "Personal",
},
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
])
expect(yield* integrations.connection.active(integrationID)).toEqual({
type: "credential",
id: personal.id,
label: "Personal",
})
expect(work.id).not.toBe(personal.id)
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY
else process.env.INTEGRATION_TEST_ACME_KEY = previous
}),
)
})
it.effect("shares concurrent OAuth credential refreshes", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let refreshes = 0
const value = Credential.OAuth.make({
type: "oauth",
methodID,
access: "refreshed",
refresh: "refresh-2",
expires: Duration.toMillis(Duration.hours(1)),
})
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () => Effect.die("unexpected authorization"),
refresh: () =>
Effect.sync(() => refreshes++).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Deferred.await(release)),
Effect.as(value),
),
}),
)
const credential = yield* credentials.create({
integrationID,
value: Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh-1",
expires: 0,
}),
})
const connection = { type: "credential" as const, id: credential.id, label: credential.label }
const first = yield* integrations.connection.resolve(connection).pipe(Effect.forkChild)
yield* Deferred.await(started)
const second = yield* integrations.connection.resolve(connection).pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(refreshes).toBe(1)
yield* Deferred.succeed(release, undefined)
expect(yield* Effect.all([Fiber.join(first), Fiber.join(second)], { concurrency: "unbounded" })).toEqual([
value,
value,
])
expect(refreshes).toBe(1)
expect((yield* credentials.get(credential.id))?.value).toEqual(value)
}),
)
it.effect("shares concurrent refresh failures and retries later", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const failure = new Error("refresh failed")
let refreshes = 0
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () => Effect.die("unexpected authorization"),
refresh: () =>
Effect.sync(() => refreshes++).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Deferred.await(release)),
Effect.andThen(Effect.fail(failure)),
),
}),
)
const credential = yield* credentials.create({
integrationID,
value: Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh",
expires: 0,
}),
})
const connection = { type: "credential" as const, id: credential.id, label: credential.label }
const first = yield* integrations.connection.resolve(connection).pipe(Effect.flip, Effect.forkChild)
yield* Deferred.await(started)
const second = yield* integrations.connection.resolve(connection).pipe(Effect.flip, Effect.forkChild)
yield* Effect.yieldNow
expect(refreshes).toBe(1)
yield* Deferred.succeed(release, undefined)
const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)], { concurrency: "unbounded" })
expect(results).toEqual([
new Integration.AuthorizationError({ cause: failure }),
new Integration.AuthorizationError({ cause: failure }),
])
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
new Integration.AuthorizationError({ cause: failure }),
)
expect(refreshes).toBe(2)
}),
)
})