integration.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. import { describe, expect } from "bun:test"
  2. import { Cause, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
  3. import * as TestClock from "effect/testing/TestClock"
  4. import { Credential } from "@opencode-ai/core/credential"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
  7. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  8. import { EventV2 } from "@opencode-ai/core/event"
  9. import { Integration } from "@opencode-ai/core/integration"
  10. import { testEffect } from "./lib/effect"
  11. const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])))
  12. const failingCredentialNode = makeGlobalNode({
  13. service: Credential.Service,
  14. layer: Layer.succeed(
  15. Credential.Service,
  16. Credential.Service.of({
  17. all: () => Effect.succeed([]),
  18. list: () => Effect.succeed([]),
  19. get: () => Effect.succeed(undefined),
  20. create: () => Effect.die(new Error("credential persistence failed")),
  21. update: () => Effect.void,
  22. remove: () => Effect.void,
  23. }),
  24. ),
  25. deps: [],
  26. })
  27. const failingIt = testEffect(
  28. AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]),
  29. )
  30. describe("Integration", () => {
  31. it.effect("registers integrations through the editor", () =>
  32. Effect.gen(function* () {
  33. const integrations = yield* Integration.Service
  34. const scope = yield* Scope.fork(yield* Scope.Scope)
  35. const openai = Integration.ID.make("openai")
  36. yield* integrations
  37. .transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
  38. .pipe(Scope.provide(scope))
  39. expect(yield* integrations.get(openai)).toEqual(
  40. new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
  41. )
  42. yield* Scope.close(scope, Exit.void)
  43. expect(yield* integrations.get(openai)).toBeUndefined()
  44. }),
  45. )
  46. it.effect("reveals the previous registration when an override closes", () =>
  47. Effect.gen(function* () {
  48. const integrations = yield* Integration.Service
  49. const id = Integration.ID.make("openai")
  50. const first = yield* Scope.fork(yield* Scope.Scope)
  51. const second = yield* Scope.fork(yield* Scope.Scope)
  52. yield* integrations
  53. .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI")))
  54. .pipe(Scope.provide(first))
  55. yield* integrations
  56. .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override")))
  57. .pipe(Scope.provide(second))
  58. expect((yield* integrations.get(id))?.name).toBe("OpenAI Override")
  59. yield* Scope.close(second, Exit.void)
  60. expect((yield* integrations.get(id))?.name).toBe("OpenAI")
  61. expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
  62. }),
  63. )
  64. it.effect("registers and overrides methods independently", () =>
  65. Effect.gen(function* () {
  66. const integrations = yield* Integration.Service
  67. const integrationID = Integration.ID.make("openai")
  68. const methodID = Integration.MethodID.make("chatgpt")
  69. const first = yield* Scope.fork(yield* Scope.Scope)
  70. const second = yield* Scope.fork(yield* Scope.Scope)
  71. const authorize = () =>
  72. Effect.succeed({
  73. mode: "auto" as const,
  74. url: "https://example.com/authorize",
  75. instructions: "Sign in",
  76. callback: Effect.never,
  77. })
  78. yield* integrations
  79. .transform((editor) =>
  80. editor.method.update({
  81. integrationID,
  82. method: { id: methodID, type: "oauth", label: "ChatGPT" },
  83. authorize,
  84. }),
  85. )
  86. .pipe(Scope.provide(first))
  87. yield* integrations
  88. .transform((editor) => {
  89. expect(editor.get(integrationID)).toEqual({ id: integrationID, name: "openai" })
  90. expect(editor.list()).toEqual([{ id: integrationID, name: "openai" }])
  91. expect(editor.method.list(integrationID)).toEqual([
  92. expect.objectContaining({ id: methodID, label: "ChatGPT" }),
  93. ])
  94. editor.method.update({
  95. integrationID,
  96. method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
  97. authorize,
  98. })
  99. })
  100. .pipe(Scope.provide(second))
  101. expect((yield* integrations.get(integrationID))?.name).toBe("openai")
  102. expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT Override" })
  103. yield* Scope.close(second, Exit.void)
  104. expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
  105. expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })])
  106. }),
  107. )
  108. it.effect("connects with a key and stores the credential", () =>
  109. Effect.gen(function* () {
  110. const integrations = yield* Integration.Service
  111. const credentials = yield* Credential.Service
  112. const events = yield* EventV2.Service
  113. const integrationID = Integration.ID.make("openai")
  114. yield* integrations.transform((editor) =>
  115. editor.method.update({
  116. integrationID,
  117. method: { type: "key", label: "API key" },
  118. }),
  119. )
  120. const updated = yield* events
  121. .subscribe(Integration.Event.Updated)
  122. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  123. yield* Effect.yieldNow
  124. yield* integrations.connection.key({
  125. integrationID,
  126. key: "secret",
  127. label: "Work",
  128. })
  129. expect(yield* credentials.list(integrationID)).toEqual([
  130. expect.objectContaining({
  131. integrationID,
  132. label: "Work",
  133. value: Credential.Key.make({ type: "key", key: "secret" }),
  134. }),
  135. ])
  136. expect((yield* Fiber.join(updated)).length).toBe(1)
  137. }),
  138. )
  139. it.effect("completes code OAuth once and stores the credential", () =>
  140. Effect.gen(function* () {
  141. const integrations = yield* Integration.Service
  142. const credentials = yield* Credential.Service
  143. const integrationID = Integration.ID.make("openai")
  144. const methodID = Integration.MethodID.make("chatgpt")
  145. yield* integrations.transform((editor) =>
  146. editor.method.update({
  147. integrationID,
  148. method: { id: methodID, type: "oauth", label: "ChatGPT" },
  149. authorize: () =>
  150. Effect.succeed({
  151. mode: "code" as const,
  152. url: "https://example.com/authorize",
  153. instructions: "Paste the code",
  154. callback: (code: string) =>
  155. Effect.succeed(
  156. Credential.OAuth.make({
  157. type: "oauth",
  158. methodID,
  159. access: "access",
  160. refresh: "refresh",
  161. expires: 1,
  162. metadata: { code },
  163. }),
  164. ),
  165. }),
  166. }),
  167. )
  168. const attempt = yield* integrations.connection.oauth({
  169. integrationID,
  170. methodID,
  171. inputs: {},
  172. label: "Personal",
  173. })
  174. expect(attempt.mode).toBe("code")
  175. yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" })
  176. expect((yield* credentials.list(integrationID))[0]).toEqual(
  177. expect.objectContaining({
  178. integrationID,
  179. label: "Personal",
  180. value: Credential.OAuth.make({
  181. type: "oauth",
  182. methodID,
  183. access: "access",
  184. refresh: "refresh",
  185. expires: 1,
  186. metadata: { code: "1234" },
  187. }),
  188. }),
  189. )
  190. }),
  191. )
  192. it.effect("keeps code attempts open when the code is missing and closes them on cancel", () =>
  193. Effect.gen(function* () {
  194. const integrations = yield* Integration.Service
  195. const credentials = yield* Credential.Service
  196. const integrationID = Integration.ID.make("openai")
  197. const methodID = Integration.MethodID.make("chatgpt")
  198. let closed = false
  199. yield* integrations.transform((editor) =>
  200. editor.method.update({
  201. integrationID,
  202. method: { id: methodID, type: "oauth", label: "ChatGPT" },
  203. authorize: () =>
  204. Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
  205. Effect.as({
  206. mode: "code" as const,
  207. url: "https://example.com/authorize",
  208. instructions: "Paste the code",
  209. callback: () => Effect.die("unexpected callback"),
  210. }),
  211. ),
  212. }),
  213. )
  214. const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
  215. expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
  216. Integration.CodeRequiredError,
  217. )
  218. expect(closed).toBe(false)
  219. yield* integrations.attempt.cancel(attempt.attemptID)
  220. expect(closed).toBe(true)
  221. expect(yield* credentials.list(integrationID)).toEqual([])
  222. }),
  223. )
  224. it.effect("completes auto OAuth in the background", () =>
  225. Effect.gen(function* () {
  226. const integrations = yield* Integration.Service
  227. const credentials = yield* Credential.Service
  228. const integrationID = Integration.ID.make("openai")
  229. const methodID = Integration.MethodID.make("browser")
  230. yield* integrations.transform((editor) =>
  231. editor.method.update({
  232. integrationID,
  233. method: { id: methodID, type: "oauth", label: "Browser" },
  234. authorize: () =>
  235. Effect.succeed({
  236. mode: "auto" as const,
  237. url: "https://example.com/authorize",
  238. instructions: "Sign in",
  239. callback: Effect.succeed(
  240. Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }),
  241. ),
  242. }),
  243. }),
  244. )
  245. const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
  246. yield* Effect.yieldNow
  247. expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
  248. status: "complete",
  249. time: attempt.time,
  250. })
  251. expect(yield* credentials.list(integrationID)).toHaveLength(1)
  252. }),
  253. )
  254. failingIt.effect("fails the attempt when credential persistence fails", () =>
  255. Effect.gen(function* () {
  256. const integrations = yield* Integration.Service
  257. const integrationID = Integration.ID.make("openai")
  258. const methodID = Integration.MethodID.make("chatgpt")
  259. yield* integrations.transform((editor) =>
  260. editor.method.update({
  261. integrationID,
  262. method: { id: methodID, type: "oauth", label: "ChatGPT" },
  263. authorize: () =>
  264. Effect.succeed({
  265. mode: "code" as const,
  266. url: "https://example.com/authorize",
  267. instructions: "Paste the code",
  268. callback: () =>
  269. Effect.succeed(
  270. Credential.OAuth.make({
  271. type: "oauth",
  272. methodID,
  273. access: "access",
  274. refresh: "refresh",
  275. expires: 1,
  276. }),
  277. ),
  278. }),
  279. }),
  280. )
  281. const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
  282. const exit = yield* integrations.attempt
  283. .complete({ attemptID: attempt.attemptID, code: "1234" })
  284. .pipe(Effect.exit)
  285. expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
  286. expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
  287. status: "failed",
  288. message: "credential persistence failed",
  289. time: attempt.time,
  290. })
  291. }),
  292. )
  293. it.effect("expires abandoned OAuth attempts", () =>
  294. Effect.gen(function* () {
  295. const integrations = yield* Integration.Service
  296. const credentials = yield* Credential.Service
  297. const integrationID = Integration.ID.make("openai")
  298. const methodID = Integration.MethodID.make("browser")
  299. let closed = false
  300. yield* integrations.transform((editor) =>
  301. editor.method.update({
  302. integrationID,
  303. method: { id: methodID, type: "oauth", label: "Browser" },
  304. authorize: () =>
  305. Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
  306. Effect.as({
  307. mode: "auto" as const,
  308. url: "https://example.com/authorize",
  309. instructions: "Sign in",
  310. callback: Effect.never,
  311. }),
  312. ),
  313. }),
  314. )
  315. const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
  316. expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
  317. yield* TestClock.adjust(Duration.minutes(10))
  318. yield* Effect.yieldNow
  319. expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
  320. status: "expired",
  321. time: attempt.time,
  322. })
  323. expect(closed).toBe(true)
  324. expect(yield* credentials.list(integrationID)).toEqual([])
  325. }),
  326. )
  327. it.effect("projects credential and env connections", () => {
  328. const integrationID = Integration.ID.make("acme")
  329. return Effect.acquireUseRelease(
  330. Effect.sync(() => {
  331. const previous = process.env.INTEGRATION_TEST_ACME_KEY
  332. process.env.INTEGRATION_TEST_ACME_KEY = "secret"
  333. delete process.env.INTEGRATION_TEST_ACME_MISSING
  334. return previous
  335. }),
  336. () =>
  337. Effect.gen(function* () {
  338. const integrations = yield* Integration.Service
  339. const credentials = yield* Credential.Service
  340. yield* integrations.transform((editor) =>
  341. editor.method.update({
  342. integrationID,
  343. method: {
  344. type: "env",
  345. names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
  346. },
  347. }),
  348. )
  349. const work = yield* credentials.create({
  350. integrationID,
  351. label: "Work",
  352. value: Credential.Key.make({ type: "key", key: "a" }),
  353. })
  354. const personal = yield* credentials.create({
  355. integrationID,
  356. label: "Personal",
  357. value: Credential.Key.make({ type: "key", key: "b" }),
  358. })
  359. // Stored credentials and detected env vars appear as connections.
  360. expect((yield* integrations.get(integrationID))?.connections).toEqual([
  361. {
  362. type: "credential",
  363. id: personal.id,
  364. label: "Personal",
  365. },
  366. { type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
  367. ])
  368. expect(yield* integrations.connection.active(integrationID)).toEqual({
  369. type: "credential",
  370. id: personal.id,
  371. label: "Personal",
  372. })
  373. expect(work.id).not.toBe(personal.id)
  374. }),
  375. (previous) =>
  376. Effect.sync(() => {
  377. if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY
  378. else process.env.INTEGRATION_TEST_ACME_KEY = previous
  379. }),
  380. )
  381. })
  382. })