| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456 |
- import { expect } from "bun:test"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { httpClient } from "@kirincode-ai/core/effect/app-node-platform"
- import { Duration, Effect, Layer, Option, Schema } from "effect"
- import { sql } from "drizzle-orm"
- import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
- import { AccountRepo } from "../../src/account/repo"
- import { Account } from "../../src/account/account"
- import {
- AccessToken,
- AccountID,
- AccountTransportError,
- DeviceCode,
- Login,
- Org,
- OrgID,
- RefreshToken,
- UserCode,
- } from "../../src/account/schema"
- import { Database } from "@kirincode-ai/core/database/database"
- import { testEffect } from "../lib/effect"
- const truncate = Layer.effectDiscard(
- Effect.gen(function* () {
- const { db } = yield* Database.Service
- yield* db.run(sql`DELETE FROM account_state`)
- yield* db.run(sql`DELETE FROM account`)
- }),
- )
- const truncateNode = LayerNode.make({ name: "truncate-account", layer: truncate, deps: [Database.node] })
- const it = testEffect(LayerNode.compile(LayerNode.group([AccountRepo.node, truncateNode])))
- const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1))
- const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10))
- const live = (client: HttpClient.HttpClient) =>
- LayerNode.compile(Account.node, [[httpClient, Layer.succeed(HttpClient.HttpClient, client)]])
- const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
- HttpClientResponse.fromWeb(
- req,
- new Response(JSON.stringify(body), {
- status,
- headers: { "content-type": "application/json" },
- }),
- )
- const encodeOrg = Schema.encodeSync(Org)
- const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
- const login = () =>
- new Login({
- code: DeviceCode.make("device-code"),
- user: UserCode.make("user-code"),
- url: "https://one.example.com/verify",
- server: "https://one.example.com",
- expiry: Duration.seconds(600),
- interval: Duration.seconds(5),
- })
- const deviceTokenClient = (body: unknown, status = 400) =>
- HttpClient.make((req) =>
- Effect.succeed(
- req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404),
- ),
- )
- const poll = (body: unknown, status = 400) =>
- Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
- it.live("login normalizes trailing slashes in the provided server URL", () =>
- Effect.gen(function* () {
- const seen: Array<string> = []
- const client = HttpClient.make((req) =>
- Effect.gen(function* () {
- seen.push(`${req.method} ${req.url}`)
- if (req.url === "https://one.example.com/auth/device/code") {
- return json(req, {
- device_code: "device-code",
- user_code: "user-code",
- verification_uri_complete: "/device?user_code=user-code",
- expires_in: 600,
- interval: 5,
- })
- }
- return json(req, {}, 404)
- }),
- )
- const result = yield* Account.use.login("https://one.example.com/").pipe(Effect.provide(live(client)))
- expect(seen).toEqual(["POST https://one.example.com/auth/device/code"])
- expect(result.server).toBe("https://one.example.com")
- expect(result.url).toBe("https://one.example.com/device?user_code=user-code")
- }),
- )
- it.live("login maps transport failures to account transport errors", () =>
- Effect.gen(function* () {
- const client = HttpClient.make((req) =>
- Effect.fail(
- new HttpClientError.HttpClientError({
- reason: new HttpClientError.TransportError({ request: req }),
- }),
- ),
- )
- const error = yield* Effect.flip(Account.use.login("https://one.example.com").pipe(Effect.provide(live(client))))
- expect(error).toBeInstanceOf(AccountTransportError)
- if (error instanceof AccountTransportError) {
- expect(error.method).toBe("POST")
- expect(error.url).toBe("https://one.example.com/auth/device/code")
- }
- }),
- )
- it.live("orgsByAccount groups orgs per account", () =>
- Effect.gen(function* () {
- yield* AccountRepo.Service.use((r) =>
- r.persistAccount({
- id: AccountID.make("user-1"),
- email: "one@example.com",
- url: "https://one.example.com",
- accessToken: AccessToken.make("at_1"),
- refreshToken: RefreshToken.make("rt_1"),
- expiry: Date.now() + outsideEagerRefreshWindow,
- orgID: Option.none(),
- }),
- )
- yield* AccountRepo.Service.use((r) =>
- r.persistAccount({
- id: AccountID.make("user-2"),
- email: "two@example.com",
- url: "https://two.example.com",
- accessToken: AccessToken.make("at_2"),
- refreshToken: RefreshToken.make("rt_2"),
- expiry: Date.now() + outsideEagerRefreshWindow,
- orgID: Option.none(),
- }),
- )
- const seen: Array<string> = []
- const client = HttpClient.make((req) =>
- Effect.gen(function* () {
- seen.push(`${req.method} ${req.url}`)
- if (req.url === "https://one.example.com/api/orgs") {
- return json(req, [org("org-1", "One")])
- }
- if (req.url === "https://two.example.com/api/orgs") {
- return json(req, [org("org-2", "Two A"), org("org-3", "Two B")])
- }
- return json(req, [], 404)
- }),
- )
- const rows = yield* Account.use.orgsByAccount().pipe(Effect.provide(live(client)))
- expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
- [AccountID.make("user-1"), [OrgID.make("org-1")]],
- [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
- ])
- expect(seen).toEqual(["GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs"])
- }),
- )
- it.live("token refresh persists the new token", () =>
- Effect.gen(function* () {
- const id = AccountID.make("user-1")
- yield* AccountRepo.Service.use((r) =>
- r.persistAccount({
- id,
- email: "user@example.com",
- url: "https://one.example.com",
- accessToken: AccessToken.make("at_old"),
- refreshToken: RefreshToken.make("rt_old"),
- expiry: Date.now() - 1_000,
- orgID: Option.none(),
- }),
- )
- const client = HttpClient.make((req) =>
- Effect.succeed(
- req.url === "https://one.example.com/auth/device/token"
- ? json(req, {
- access_token: "at_new",
- refresh_token: "rt_new",
- expires_in: 60,
- })
- : json(req, {}, 404),
- ),
- )
- const token = yield* Account.use.token(id).pipe(Effect.provide(live(client)))
- expect(Option.getOrThrow(token)).toBeDefined()
- expect(String(Option.getOrThrow(token))).toBe("at_new")
- const row = yield* AccountRepo.use.getRow(id)
- const value = Option.getOrThrow(row)
- expect(value.access_token).toBe(AccessToken.make("at_new"))
- expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
- expect(value.token_expiry).toBeGreaterThan(Date.now())
- }),
- )
- it.live("token refreshes before expiry when inside the eager refresh window", () =>
- Effect.gen(function* () {
- const id = AccountID.make("user-1")
- yield* AccountRepo.Service.use((r) =>
- r.persistAccount({
- id,
- email: "user@example.com",
- url: "https://one.example.com",
- accessToken: AccessToken.make("at_old"),
- refreshToken: RefreshToken.make("rt_old"),
- expiry: Date.now() + insideEagerRefreshWindow,
- orgID: Option.none(),
- }),
- )
- let refreshCalls = 0
- const client = HttpClient.make((req) =>
- Effect.promise(async () => {
- if (req.url === "https://one.example.com/auth/device/token") {
- refreshCalls += 1
- return json(req, {
- access_token: "at_new",
- refresh_token: "rt_new",
- expires_in: 60,
- })
- }
- return json(req, {}, 404)
- }),
- )
- const token = yield* Account.use.token(id).pipe(Effect.provide(live(client)))
- expect(String(Option.getOrThrow(token))).toBe("at_new")
- expect(refreshCalls).toBe(1)
- const row = yield* AccountRepo.use.getRow(id)
- const value = Option.getOrThrow(row)
- expect(value.access_token).toBe(AccessToken.make("at_new"))
- expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
- }),
- )
- it.live("concurrent config and token requests coalesce token refresh", () =>
- Effect.gen(function* () {
- const id = AccountID.make("user-1")
- yield* AccountRepo.Service.use((r) =>
- r.persistAccount({
- id,
- email: "user@example.com",
- url: "https://one.example.com",
- accessToken: AccessToken.make("at_old"),
- refreshToken: RefreshToken.make("rt_old"),
- expiry: Date.now() - 1_000,
- orgID: Option.some(OrgID.make("org-9")),
- }),
- )
- let refreshCalls = 0
- const client = HttpClient.make((req) =>
- Effect.promise(async () => {
- if (req.url === "https://one.example.com/auth/device/token") {
- refreshCalls += 1
- if (refreshCalls === 1) {
- await new Promise((resolve) => setTimeout(resolve, 25))
- return json(req, {
- access_token: "at_new",
- refresh_token: "rt_new",
- expires_in: 60,
- })
- }
- return json(
- req,
- {
- error: "invalid_grant",
- error_description: "refresh token already used",
- },
- 400,
- )
- }
- if (req.url === "https://one.example.com/api/config") {
- return json(req, { config: { theme: "light", seats: 5 } })
- }
- return json(req, {}, 404)
- }),
- )
- const [cfg, token] = yield* Account.Service.use((s) =>
- Effect.all([s.config(id, OrgID.make("org-9")), s.token(id)], { concurrency: 2 }),
- ).pipe(Effect.provide(live(client)))
- expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
- expect(String(Option.getOrThrow(token))).toBe("at_new")
- expect(refreshCalls).toBe(1)
- const row = yield* AccountRepo.use.getRow(id)
- const value = Option.getOrThrow(row)
- expect(value.access_token).toBe(AccessToken.make("at_new"))
- expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
- }),
- )
- it.live("config sends the selected org header", () =>
- Effect.gen(function* () {
- const id = AccountID.make("user-1")
- yield* AccountRepo.Service.use((r) =>
- r.persistAccount({
- id,
- email: "user@example.com",
- url: "https://one.example.com",
- accessToken: AccessToken.make("at_1"),
- refreshToken: RefreshToken.make("rt_1"),
- expiry: Date.now() + outsideEagerRefreshWindow,
- orgID: Option.none(),
- }),
- )
- const seen: { auth?: string; org?: string } = {}
- const client = HttpClient.make((req) =>
- Effect.gen(function* () {
- seen.auth = req.headers.authorization
- seen.org = req.headers["x-org-id"]
- if (req.url === "https://one.example.com/api/config") {
- return json(req, { config: { theme: "light", seats: 5 } })
- }
- return json(req, {}, 404)
- }),
- )
- const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
- expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
- expect(seen).toEqual({
- auth: "Bearer at_1",
- org: "org-9",
- })
- }),
- )
- it.live("poll stores the account and first org on success", () =>
- Effect.gen(function* () {
- const client = HttpClient.make((req) =>
- Effect.succeed(
- req.url === "https://one.example.com/auth/device/token"
- ? json(req, {
- access_token: "at_1",
- refresh_token: "rt_1",
- token_type: "Bearer",
- expires_in: 60,
- })
- : req.url === "https://one.example.com/api/user"
- ? json(req, { id: "user-1", email: "user@example.com" })
- : req.url === "https://one.example.com/api/orgs"
- ? json(req, [org("org-1", "One")])
- : json(req, {}, 404),
- ),
- )
- const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
- expect(res._tag).toBe("PollSuccess")
- if (res._tag === "PollSuccess") {
- expect(res.email).toBe("user@example.com")
- }
- const active = yield* AccountRepo.use.active()
- expect(Option.getOrThrow(active)).toEqual(
- expect.objectContaining({
- id: "user-1",
- email: "user@example.com",
- active_org_id: "org-1",
- }),
- )
- }),
- )
- for (const [name, body, expectedTag] of [
- [
- "pending",
- {
- error: "authorization_pending",
- error_description: "The authorization request is still pending",
- },
- "PollPending",
- ],
- [
- "slow",
- {
- error: "slow_down",
- error_description: "Polling too frequently, please slow down",
- },
- "PollSlow",
- ],
- [
- "denied",
- {
- error: "access_denied",
- error_description: "The authorization request was denied",
- },
- "PollDenied",
- ],
- [
- "expired",
- {
- error: "expired_token",
- error_description: "The device code has expired",
- },
- "PollExpired",
- ],
- ] as const) {
- it.live(`poll returns ${name} for ${body.error}`, () =>
- Effect.gen(function* () {
- const result = yield* poll(body)
- expect(result._tag).toBe(expectedTag)
- }),
- )
- }
- it.live("poll returns poll error for other OAuth errors", () =>
- Effect.gen(function* () {
- const result = yield* poll({
- error: "server_error",
- error_description: "An unexpected error occurred",
- })
- expect(result._tag).toBe("PollError")
- if (result._tag === "PollError") {
- expect(String(result.cause)).toContain("server_error")
- }
- }),
- )
|