service.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. import { expect } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { httpClient } from "@kirincode-ai/core/effect/app-node-platform"
  4. import { Duration, Effect, Layer, Option, Schema } from "effect"
  5. import { sql } from "drizzle-orm"
  6. import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
  7. import { AccountRepo } from "../../src/account/repo"
  8. import { Account } from "../../src/account/account"
  9. import {
  10. AccessToken,
  11. AccountID,
  12. AccountTransportError,
  13. DeviceCode,
  14. Login,
  15. Org,
  16. OrgID,
  17. RefreshToken,
  18. UserCode,
  19. } from "../../src/account/schema"
  20. import { Database } from "@kirincode-ai/core/database/database"
  21. import { testEffect } from "../lib/effect"
  22. const truncate = Layer.effectDiscard(
  23. Effect.gen(function* () {
  24. const { db } = yield* Database.Service
  25. yield* db.run(sql`DELETE FROM account_state`)
  26. yield* db.run(sql`DELETE FROM account`)
  27. }),
  28. )
  29. const truncateNode = LayerNode.make({ name: "truncate-account", layer: truncate, deps: [Database.node] })
  30. const it = testEffect(LayerNode.compile(LayerNode.group([AccountRepo.node, truncateNode])))
  31. const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1))
  32. const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10))
  33. const live = (client: HttpClient.HttpClient) =>
  34. LayerNode.compile(Account.node, [[httpClient, Layer.succeed(HttpClient.HttpClient, client)]])
  35. const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
  36. HttpClientResponse.fromWeb(
  37. req,
  38. new Response(JSON.stringify(body), {
  39. status,
  40. headers: { "content-type": "application/json" },
  41. }),
  42. )
  43. const encodeOrg = Schema.encodeSync(Org)
  44. const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
  45. const login = () =>
  46. new Login({
  47. code: DeviceCode.make("device-code"),
  48. user: UserCode.make("user-code"),
  49. url: "https://one.example.com/verify",
  50. server: "https://one.example.com",
  51. expiry: Duration.seconds(600),
  52. interval: Duration.seconds(5),
  53. })
  54. const deviceTokenClient = (body: unknown, status = 400) =>
  55. HttpClient.make((req) =>
  56. Effect.succeed(
  57. req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404),
  58. ),
  59. )
  60. const poll = (body: unknown, status = 400) =>
  61. Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
  62. it.live("login normalizes trailing slashes in the provided server URL", () =>
  63. Effect.gen(function* () {
  64. const seen: Array<string> = []
  65. const client = HttpClient.make((req) =>
  66. Effect.gen(function* () {
  67. seen.push(`${req.method} ${req.url}`)
  68. if (req.url === "https://one.example.com/auth/device/code") {
  69. return json(req, {
  70. device_code: "device-code",
  71. user_code: "user-code",
  72. verification_uri_complete: "/device?user_code=user-code",
  73. expires_in: 600,
  74. interval: 5,
  75. })
  76. }
  77. return json(req, {}, 404)
  78. }),
  79. )
  80. const result = yield* Account.use.login("https://one.example.com/").pipe(Effect.provide(live(client)))
  81. expect(seen).toEqual(["POST https://one.example.com/auth/device/code"])
  82. expect(result.server).toBe("https://one.example.com")
  83. expect(result.url).toBe("https://one.example.com/device?user_code=user-code")
  84. }),
  85. )
  86. it.live("login maps transport failures to account transport errors", () =>
  87. Effect.gen(function* () {
  88. const client = HttpClient.make((req) =>
  89. Effect.fail(
  90. new HttpClientError.HttpClientError({
  91. reason: new HttpClientError.TransportError({ request: req }),
  92. }),
  93. ),
  94. )
  95. const error = yield* Effect.flip(Account.use.login("https://one.example.com").pipe(Effect.provide(live(client))))
  96. expect(error).toBeInstanceOf(AccountTransportError)
  97. if (error instanceof AccountTransportError) {
  98. expect(error.method).toBe("POST")
  99. expect(error.url).toBe("https://one.example.com/auth/device/code")
  100. }
  101. }),
  102. )
  103. it.live("orgsByAccount groups orgs per account", () =>
  104. Effect.gen(function* () {
  105. yield* AccountRepo.Service.use((r) =>
  106. r.persistAccount({
  107. id: AccountID.make("user-1"),
  108. email: "one@example.com",
  109. url: "https://one.example.com",
  110. accessToken: AccessToken.make("at_1"),
  111. refreshToken: RefreshToken.make("rt_1"),
  112. expiry: Date.now() + outsideEagerRefreshWindow,
  113. orgID: Option.none(),
  114. }),
  115. )
  116. yield* AccountRepo.Service.use((r) =>
  117. r.persistAccount({
  118. id: AccountID.make("user-2"),
  119. email: "two@example.com",
  120. url: "https://two.example.com",
  121. accessToken: AccessToken.make("at_2"),
  122. refreshToken: RefreshToken.make("rt_2"),
  123. expiry: Date.now() + outsideEagerRefreshWindow,
  124. orgID: Option.none(),
  125. }),
  126. )
  127. const seen: Array<string> = []
  128. const client = HttpClient.make((req) =>
  129. Effect.gen(function* () {
  130. seen.push(`${req.method} ${req.url}`)
  131. if (req.url === "https://one.example.com/api/orgs") {
  132. return json(req, [org("org-1", "One")])
  133. }
  134. if (req.url === "https://two.example.com/api/orgs") {
  135. return json(req, [org("org-2", "Two A"), org("org-3", "Two B")])
  136. }
  137. return json(req, [], 404)
  138. }),
  139. )
  140. const rows = yield* Account.use.orgsByAccount().pipe(Effect.provide(live(client)))
  141. expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
  142. [AccountID.make("user-1"), [OrgID.make("org-1")]],
  143. [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
  144. ])
  145. expect(seen).toEqual(["GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs"])
  146. }),
  147. )
  148. it.live("token refresh persists the new token", () =>
  149. Effect.gen(function* () {
  150. const id = AccountID.make("user-1")
  151. yield* AccountRepo.Service.use((r) =>
  152. r.persistAccount({
  153. id,
  154. email: "user@example.com",
  155. url: "https://one.example.com",
  156. accessToken: AccessToken.make("at_old"),
  157. refreshToken: RefreshToken.make("rt_old"),
  158. expiry: Date.now() - 1_000,
  159. orgID: Option.none(),
  160. }),
  161. )
  162. const client = HttpClient.make((req) =>
  163. Effect.succeed(
  164. req.url === "https://one.example.com/auth/device/token"
  165. ? json(req, {
  166. access_token: "at_new",
  167. refresh_token: "rt_new",
  168. expires_in: 60,
  169. })
  170. : json(req, {}, 404),
  171. ),
  172. )
  173. const token = yield* Account.use.token(id).pipe(Effect.provide(live(client)))
  174. expect(Option.getOrThrow(token)).toBeDefined()
  175. expect(String(Option.getOrThrow(token))).toBe("at_new")
  176. const row = yield* AccountRepo.use.getRow(id)
  177. const value = Option.getOrThrow(row)
  178. expect(value.access_token).toBe(AccessToken.make("at_new"))
  179. expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
  180. expect(value.token_expiry).toBeGreaterThan(Date.now())
  181. }),
  182. )
  183. it.live("token refreshes before expiry when inside the eager refresh window", () =>
  184. Effect.gen(function* () {
  185. const id = AccountID.make("user-1")
  186. yield* AccountRepo.Service.use((r) =>
  187. r.persistAccount({
  188. id,
  189. email: "user@example.com",
  190. url: "https://one.example.com",
  191. accessToken: AccessToken.make("at_old"),
  192. refreshToken: RefreshToken.make("rt_old"),
  193. expiry: Date.now() + insideEagerRefreshWindow,
  194. orgID: Option.none(),
  195. }),
  196. )
  197. let refreshCalls = 0
  198. const client = HttpClient.make((req) =>
  199. Effect.promise(async () => {
  200. if (req.url === "https://one.example.com/auth/device/token") {
  201. refreshCalls += 1
  202. return json(req, {
  203. access_token: "at_new",
  204. refresh_token: "rt_new",
  205. expires_in: 60,
  206. })
  207. }
  208. return json(req, {}, 404)
  209. }),
  210. )
  211. const token = yield* Account.use.token(id).pipe(Effect.provide(live(client)))
  212. expect(String(Option.getOrThrow(token))).toBe("at_new")
  213. expect(refreshCalls).toBe(1)
  214. const row = yield* AccountRepo.use.getRow(id)
  215. const value = Option.getOrThrow(row)
  216. expect(value.access_token).toBe(AccessToken.make("at_new"))
  217. expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
  218. }),
  219. )
  220. it.live("concurrent config and token requests coalesce token refresh", () =>
  221. Effect.gen(function* () {
  222. const id = AccountID.make("user-1")
  223. yield* AccountRepo.Service.use((r) =>
  224. r.persistAccount({
  225. id,
  226. email: "user@example.com",
  227. url: "https://one.example.com",
  228. accessToken: AccessToken.make("at_old"),
  229. refreshToken: RefreshToken.make("rt_old"),
  230. expiry: Date.now() - 1_000,
  231. orgID: Option.some(OrgID.make("org-9")),
  232. }),
  233. )
  234. let refreshCalls = 0
  235. const client = HttpClient.make((req) =>
  236. Effect.promise(async () => {
  237. if (req.url === "https://one.example.com/auth/device/token") {
  238. refreshCalls += 1
  239. if (refreshCalls === 1) {
  240. await new Promise((resolve) => setTimeout(resolve, 25))
  241. return json(req, {
  242. access_token: "at_new",
  243. refresh_token: "rt_new",
  244. expires_in: 60,
  245. })
  246. }
  247. return json(
  248. req,
  249. {
  250. error: "invalid_grant",
  251. error_description: "refresh token already used",
  252. },
  253. 400,
  254. )
  255. }
  256. if (req.url === "https://one.example.com/api/config") {
  257. return json(req, { config: { theme: "light", seats: 5 } })
  258. }
  259. return json(req, {}, 404)
  260. }),
  261. )
  262. const [cfg, token] = yield* Account.Service.use((s) =>
  263. Effect.all([s.config(id, OrgID.make("org-9")), s.token(id)], { concurrency: 2 }),
  264. ).pipe(Effect.provide(live(client)))
  265. expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
  266. expect(String(Option.getOrThrow(token))).toBe("at_new")
  267. expect(refreshCalls).toBe(1)
  268. const row = yield* AccountRepo.use.getRow(id)
  269. const value = Option.getOrThrow(row)
  270. expect(value.access_token).toBe(AccessToken.make("at_new"))
  271. expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
  272. }),
  273. )
  274. it.live("config sends the selected org header", () =>
  275. Effect.gen(function* () {
  276. const id = AccountID.make("user-1")
  277. yield* AccountRepo.Service.use((r) =>
  278. r.persistAccount({
  279. id,
  280. email: "user@example.com",
  281. url: "https://one.example.com",
  282. accessToken: AccessToken.make("at_1"),
  283. refreshToken: RefreshToken.make("rt_1"),
  284. expiry: Date.now() + outsideEagerRefreshWindow,
  285. orgID: Option.none(),
  286. }),
  287. )
  288. const seen: { auth?: string; org?: string } = {}
  289. const client = HttpClient.make((req) =>
  290. Effect.gen(function* () {
  291. seen.auth = req.headers.authorization
  292. seen.org = req.headers["x-org-id"]
  293. if (req.url === "https://one.example.com/api/config") {
  294. return json(req, { config: { theme: "light", seats: 5 } })
  295. }
  296. return json(req, {}, 404)
  297. }),
  298. )
  299. const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
  300. expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
  301. expect(seen).toEqual({
  302. auth: "Bearer at_1",
  303. org: "org-9",
  304. })
  305. }),
  306. )
  307. it.live("poll stores the account and first org on success", () =>
  308. Effect.gen(function* () {
  309. const client = HttpClient.make((req) =>
  310. Effect.succeed(
  311. req.url === "https://one.example.com/auth/device/token"
  312. ? json(req, {
  313. access_token: "at_1",
  314. refresh_token: "rt_1",
  315. token_type: "Bearer",
  316. expires_in: 60,
  317. })
  318. : req.url === "https://one.example.com/api/user"
  319. ? json(req, { id: "user-1", email: "user@example.com" })
  320. : req.url === "https://one.example.com/api/orgs"
  321. ? json(req, [org("org-1", "One")])
  322. : json(req, {}, 404),
  323. ),
  324. )
  325. const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
  326. expect(res._tag).toBe("PollSuccess")
  327. if (res._tag === "PollSuccess") {
  328. expect(res.email).toBe("user@example.com")
  329. }
  330. const active = yield* AccountRepo.use.active()
  331. expect(Option.getOrThrow(active)).toEqual(
  332. expect.objectContaining({
  333. id: "user-1",
  334. email: "user@example.com",
  335. active_org_id: "org-1",
  336. }),
  337. )
  338. }),
  339. )
  340. for (const [name, body, expectedTag] of [
  341. [
  342. "pending",
  343. {
  344. error: "authorization_pending",
  345. error_description: "The authorization request is still pending",
  346. },
  347. "PollPending",
  348. ],
  349. [
  350. "slow",
  351. {
  352. error: "slow_down",
  353. error_description: "Polling too frequently, please slow down",
  354. },
  355. "PollSlow",
  356. ],
  357. [
  358. "denied",
  359. {
  360. error: "access_denied",
  361. error_description: "The authorization request was denied",
  362. },
  363. "PollDenied",
  364. ],
  365. [
  366. "expired",
  367. {
  368. error: "expired_token",
  369. error_description: "The device code has expired",
  370. },
  371. "PollExpired",
  372. ],
  373. ] as const) {
  374. it.live(`poll returns ${name} for ${body.error}`, () =>
  375. Effect.gen(function* () {
  376. const result = yield* poll(body)
  377. expect(result._tag).toBe(expectedTag)
  378. }),
  379. )
  380. }
  381. it.live("poll returns poll error for other OAuth errors", () =>
  382. Effect.gen(function* () {
  383. const result = yield* poll({
  384. error: "server_error",
  385. error_description: "An unexpected error occurred",
  386. })
  387. expect(result._tag).toBe("PollError")
  388. if (result._tag === "PollError") {
  389. expect(String(result.cause)).toContain("server_error")
  390. }
  391. }),
  392. )