+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
457 lines
14 KiB
TypeScript
457 lines
14 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("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")
|
|
}
|
|
}),
|
|
)
|