+21







![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)
![opencode-agent[bot]](/assets/img/avatar_default.png)
opencode-agent[bot]
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Aiden Cline
usrnk1
Aarav Sareen
Luke Parker
Brendan Allan
Kit Langton
James Long
Adam
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Jay
Simon Klee
Jay
Jack
David Hill
Brendan Allan
opencode
Aiden Cline
James Long
Dustin Deus
冯基魁
Frank
Aiden Cline
Victor Navarro
Dax Raad
Dax
Nabs
Vladimir Glafirov
AidenGeunGeun
Mark
634386fe0f
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Victor Navarro <vn4varro@gmail.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Nabs <nabil@instafork.com> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com> Co-authored-by: Mark <geraint0923@users.noreply.github.com>
504 lines
15 KiB
TypeScript
504 lines
15 KiB
TypeScript
import { expect } from "bun:test"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
import { httpClient } from "@opencode-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 "@opencode-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("remove switches to another org when the active account is removed", () =>
|
|
Effect.gen(function* () {
|
|
const first = AccountID.make("user-1")
|
|
const second = AccountID.make("user-2")
|
|
|
|
yield* AccountRepo.Service.use((r) =>
|
|
r.persistAccount({
|
|
id: first,
|
|
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.some(OrgID.make("org-1")),
|
|
}),
|
|
)
|
|
|
|
yield* AccountRepo.Service.use((r) =>
|
|
r.persistAccount({
|
|
id: second,
|
|
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.some(OrgID.make("org-2")),
|
|
}),
|
|
)
|
|
|
|
const client = HttpClient.make((req) =>
|
|
Effect.succeed(
|
|
req.url === "https://one.example.com/api/orgs" ? json(req, [org("org-1", "One")]) : json(req, [], 404),
|
|
),
|
|
)
|
|
|
|
yield* Account.use.remove(second).pipe(Effect.provide(live(client)))
|
|
|
|
const active = yield* AccountRepo.use.active()
|
|
expect(Option.getOrThrow(active)).toEqual(
|
|
expect.objectContaining({
|
|
id: first,
|
|
active_org_id: OrgID.make("org-1"),
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
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")
|
|
}
|
|
}),
|
|
)
|