credential.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import path from "path"
  2. import { describe, expect } from "bun:test"
  3. import { Effect, Fiber, Layer, Stream } from "effect"
  4. import { Credential } from "@opencode-ai/core/credential"
  5. import { Connector } from "@opencode-ai/core/connector"
  6. import { Database } from "@opencode-ai/core/database/database"
  7. import { EventV2 } from "@opencode-ai/core/event"
  8. import { FSUtil } from "@opencode-ai/core/fs-util"
  9. import { Global } from "@opencode-ai/core/global"
  10. import { PluginV2 } from "@opencode-ai/core/plugin"
  11. import { tmpdir } from "./fixture/tmpdir"
  12. import { testEffect } from "./lib/effect"
  13. const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))
  14. function testLayer(directory: string) {
  15. return Credential.layer.pipe(
  16. Layer.fresh,
  17. Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)),
  18. Layer.provideMerge(EventV2.defaultLayer),
  19. )
  20. }
  21. describe("Credential", () => {
  22. it.live("imports supported legacy auth.json credentials once", () =>
  23. Effect.acquireRelease(
  24. Effect.promise(() => tmpdir()),
  25. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  26. ).pipe(
  27. Effect.flatMap((tmp) =>
  28. Effect.gen(function* () {
  29. yield* Effect.promise(() =>
  30. Bun.write(
  31. path.join(tmp.path, "auth.json"),
  32. JSON.stringify({
  33. openai: {
  34. type: "oauth",
  35. refresh: "refresh",
  36. access: "access",
  37. expires: 123,
  38. accountId: "account",
  39. },
  40. azure: { type: "api", key: "key", metadata: { resourceName: "resource" } },
  41. ignored: { type: "wellknown", key: "TOKEN", token: "secret" },
  42. }),
  43. ),
  44. )
  45. const database = Database.layerFromPath(path.join(tmp.path, "credential.db")).pipe(Layer.fresh)
  46. const global = Global.layerWith({ data: tmp.path })
  47. const importer = Credential.legacyImportLayer.pipe(
  48. Layer.provide(database),
  49. Layer.provide(FSUtil.defaultLayer),
  50. Layer.provide(global),
  51. )
  52. const credentials = Credential.layer.pipe(
  53. Layer.provide(database),
  54. Layer.provide(EventV2.defaultLayer),
  55. Layer.provideMerge(importer),
  56. )
  57. const result = yield* Effect.gen(function* () {
  58. const service = yield* Credential.Service
  59. return yield* service.all()
  60. }).pipe(Effect.provide(credentials), Effect.scoped)
  61. expect(result).toHaveLength(2)
  62. expect(result).toContainEqual(
  63. expect.objectContaining({
  64. connectorID: Connector.ID.make("openai"),
  65. methodID: Connector.MethodID.make("chatgpt-browser"),
  66. label: "Imported",
  67. value: expect.objectContaining({
  68. type: "oauth",
  69. refresh: "refresh",
  70. access: "access",
  71. expires: 123,
  72. metadata: { accountID: "account" },
  73. }),
  74. }),
  75. )
  76. expect(result).toContainEqual(
  77. expect.objectContaining({
  78. connectorID: Connector.ID.make("azure"),
  79. methodID: Connector.MethodID.make("api-key"),
  80. value: expect.objectContaining({ type: "key", key: "key", metadata: { resourceName: "resource" } }),
  81. }),
  82. )
  83. yield* importer.pipe(Layer.build, Effect.scoped)
  84. const after = yield* Effect.gen(function* () {
  85. return yield* (yield* Credential.Service).all()
  86. }).pipe(Effect.provide(credentials), Effect.scoped)
  87. expect(after).toHaveLength(2)
  88. }),
  89. ),
  90. ),
  91. )
  92. it.live("emits credential lifecycle events", () =>
  93. Effect.acquireRelease(
  94. Effect.promise(() => tmpdir()),
  95. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  96. ).pipe(
  97. Effect.flatMap((tmp) =>
  98. Effect.gen(function* () {
  99. const credentials = yield* Credential.Service
  100. const eventSvc = yield* EventV2.Service
  101. const addedFiber = yield* eventSvc
  102. .subscribe(Credential.Event.Added)
  103. .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
  104. const switchedFiber = yield* eventSvc
  105. .subscribe(Credential.Event.Switched)
  106. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  107. const removedFiber = yield* eventSvc
  108. .subscribe(Credential.Event.Removed)
  109. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  110. yield* Effect.yieldNow
  111. const first = yield* credentials.create({
  112. connectorID: Connector.ID.make("lifecycle"),
  113. methodID: Connector.MethodID.make("key"),
  114. value: new Credential.Key({ type: "key", key: "raw-key" }),
  115. })
  116. expect(first).toBeDefined()
  117. if (!first) return
  118. expect(first.label).toBe("default")
  119. expect(first.value.type).toBe("key")
  120. if (first.value.type === "key") expect(first.value.key).toBe("raw-key")
  121. yield* credentials.update(first.id, { label: "keep" })
  122. const updated = yield* credentials.get(first.id)
  123. expect(updated?.label).toBe("keep")
  124. expect(updated?.value.type).toBe("key")
  125. if (updated?.value.type === "key") expect(updated.value.key).toBe("raw-key")
  126. const second = yield* credentials.create({
  127. connectorID: Connector.ID.make("lifecycle"),
  128. methodID: Connector.MethodID.make("key"),
  129. value: new Credential.Key({ type: "key", key: "second-key" }),
  130. })
  131. expect(second).toBeDefined()
  132. if (!second) return
  133. yield* credentials.remove(second.id)
  134. const added = Array.from(yield* Fiber.join(addedFiber))
  135. const switched = Array.from(yield* Fiber.join(switchedFiber))
  136. const removed = Array.from(yield* Fiber.join(removedFiber))
  137. expect(added.map((event) => event.data.credential.id)).toEqual([first.id, second.id])
  138. expect(switched.map((event) => event.data)).toEqual([
  139. { connectorID: Connector.ID.make("lifecycle"), from: undefined, to: first.id },
  140. { connectorID: Connector.ID.make("lifecycle"), from: first.id, to: second.id },
  141. { connectorID: Connector.ID.make("lifecycle"), from: second.id, to: first.id },
  142. ])
  143. expect(removed[0]?.data.credential.id).toBe(second.id)
  144. }).pipe(Effect.provide(testLayer(tmp.path))),
  145. ),
  146. ),
  147. )
  148. it.live("always switches to newly created credentials", () =>
  149. Effect.acquireRelease(
  150. Effect.promise(() => tmpdir()),
  151. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  152. ).pipe(
  153. Effect.flatMap((tmp) =>
  154. Effect.gen(function* () {
  155. const credentials = yield* Credential.Service
  156. const eventSvc = yield* EventV2.Service
  157. const switchedFiber = yield* eventSvc
  158. .subscribe(Credential.Event.Switched)
  159. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  160. yield* Effect.yieldNow
  161. const first = yield* credentials.create({
  162. connectorID: Connector.ID.make("switch"),
  163. methodID: Connector.MethodID.make("key"),
  164. value: new Credential.Key({ type: "key", key: "first-key" }),
  165. })
  166. const second = yield* credentials.create({
  167. connectorID: Connector.ID.make("switch"),
  168. methodID: Connector.MethodID.make("key"),
  169. value: new Credential.Key({ type: "key", key: "second-key" }),
  170. })
  171. const third = yield* credentials.create({
  172. connectorID: Connector.ID.make("switch"),
  173. methodID: Connector.MethodID.make("key"),
  174. value: new Credential.Key({ type: "key", key: "third-key" }),
  175. })
  176. expect(first).toBeDefined()
  177. expect(second).toBeDefined()
  178. expect(third).toBeDefined()
  179. if (!first || !second || !third) return
  180. expect((yield* credentials.active(Connector.ID.make("switch")))?.id).toBe(third.id)
  181. expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
  182. { connectorID: Connector.ID.make("switch"), from: undefined, to: first.id },
  183. { connectorID: Connector.ID.make("switch"), from: first.id, to: second.id },
  184. { connectorID: Connector.ID.make("switch"), from: second.id, to: third.id },
  185. ])
  186. }).pipe(Effect.provide(testLayer(tmp.path))),
  187. ),
  188. ),
  189. )
  190. })