integration.test.ts 14 KB

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