Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/api/powersync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ const powersyncSettings: Settings = {
haystackApiKey: '',
haystackWorkspace: '',
haystackPipelines: '',
codingAgentWorkspaceWsUrl: '',
codingAgentBrokerUrl: '',
codingAgentServiceToken: '',
}

describe('PowerSync API', () => {
Expand Down
120 changes: 120 additions & 0 deletions backend/src/coding-agent/github-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/**
* Unit tests for the coding-agent GitHub HTTP routes. They drive the Elysia app
* via `.handle()` with a fake `auth` (in-memory `getSession`) and injected broker
* seams — no bound port, no real broker, no DB. Asserts the discriminated DTOs
* and, crucially, that the broker is called with the *session* user.id (not any
* request-supplied id).
*/

import type { Auth } from '@/auth/elysia-plugin'
import { createTestSettings } from '@/test-utils/settings'
import { describe, expect, it } from 'bun:test'
import type { AuthorizeUrlResult, BrokerGithubOptions, GithubStatusResult } from './github'
import { createCodingAgentGithubRoutes, type CodingAgentGithubDeps } from './github-routes'

const sessionUser = (id: string) =>
({ api: { getSession: async () => ({ user: { id, isAnonymous: false } }) } }) as unknown as Auth
const noSession = { api: { getSession: async () => null } } as unknown as Auth

const brokerSettings = createTestSettings({
codingAgentBrokerUrl: 'https://broker.test',
codingAgentServiceToken: 'svc',
})

const get = (app: ReturnType<typeof createCodingAgentGithubRoutes>, path: string) =>
app.handle(new Request(`http://localhost${path}`, { headers: { Authorization: 'Bearer end-user-tok' } }))

describe('GET /coding-agent/github/authorize-url', () => {
it('401s without a session', async () => {
const app = createCodingAgentGithubRoutes(brokerSettings, noSession)
const res = await get(app, '/coding-agent/github/authorize-url')
expect(res.status).toBe(401)
})

it('returns configured:false when the broker is not configured', async () => {
const app = createCodingAgentGithubRoutes(createTestSettings(), sessionUser('u1'))
const res = await get(app, '/coding-agent/github/authorize-url')
expect(await res.json()).toEqual({ configured: false })
})

it('returns the url and calls the broker with the SESSION user.id', async () => {
let seenUserId = ''
const deps: CodingAgentGithubDeps = {
fetchAuthorizeUrlFn: async (_opts: BrokerGithubOptions, userId: string): Promise<AuthorizeUrlResult> => {
seenUserId = userId
return { status: 'ok', url: 'https://github.com/login/oauth/authorize?state=x' }
},
}
const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('alice'), deps)
const res = await get(app, '/coding-agent/github/authorize-url')
expect(await res.json()).toEqual({
configured: true,
status: 'ok',
url: 'https://github.com/login/oauth/authorize?state=x',
})
expect(seenUserId).toBe('alice') // identity comes from the session, not the request
})

it('maps a disabled broker to status:disabled', async () => {
const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('u1'), {
fetchAuthorizeUrlFn: async () => ({ status: 'disabled' }),
})
const res = await get(app, '/coding-agent/github/authorize-url')
expect(await res.json()).toEqual({ configured: true, status: 'disabled' })
})

it('maps a broker failure to status:failed (no reason leaked to the client)', async () => {
const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('u1'), {
fetchAuthorizeUrlFn: async () => ({ status: 'failed', reason: 'broker 502' }),
})
const res = await get(app, '/coding-agent/github/authorize-url')
expect(await res.json()).toEqual({ configured: true, status: 'failed' })
})
})

describe('GET /coding-agent/github/status', () => {
it('401s without a session', async () => {
const app = createCodingAgentGithubRoutes(brokerSettings, noSession)
const res = await get(app, '/coding-agent/github/status')
expect(res.status).toBe(401)
})

it('returns configured:false when the broker is not configured', async () => {
const app = createCodingAgentGithubRoutes(createTestSettings(), sessionUser('u1'))
const res = await get(app, '/coding-agent/github/status')
expect(await res.json()).toEqual({ configured: false })
})

it('reports connected:true and passes the session user.id', async () => {
let seenUserId = ''
const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('bob'), {
fetchGithubStatusFn: async (_opts: BrokerGithubOptions, userId: string): Promise<GithubStatusResult> => {
seenUserId = userId
return { status: 'ok', connected: true }
},
})
const res = await get(app, '/coding-agent/github/status')
expect(await res.json()).toEqual({ configured: true, status: 'ok', connected: true })
expect(seenUserId).toBe('bob')
})

it('reports connected:false', async () => {
const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('u1'), {
fetchGithubStatusFn: async () => ({ status: 'ok', connected: false }),
})
const res = await get(app, '/coding-agent/github/status')
expect(await res.json()).toEqual({ configured: true, status: 'ok', connected: false })
})

it('maps a broker failure to status:failed', async () => {
const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('u1'), {
fetchGithubStatusFn: async () => ({ status: 'failed', reason: 'broker timeout' }),
})
const res = await get(app, '/coding-agent/github/status')
expect(await res.json()).toEqual({ configured: true, status: 'failed' })
})
})
108 changes: 108 additions & 0 deletions backend/src/coding-agent/github-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/**
* User-facing GitHub connect/status HTTP endpoints backing the built-in
* assistant's `github_connect` / `github_status` tools (the design's "MCP
* management plane").
*
* - `GET /coding-agent/github/authorize-url` → the per-user GitHub OAuth
* authorize URL the developer clicks to connect their account.
* - `GET /coding-agent/github/status` → whether the developer has connected.
*
* Both require an authenticated session (`{ auth: true }`); the developer's
* Better-Auth `user.id` is resolved server-side from that session and forwarded
* to the broker as `x-tb-user-id`. It is NEVER a request/tool argument, so the
* model invoking the tool cannot act as another user. This reuses the same
* broker base URL + service token as the #967 provisioning path.
*/

import type { Auth } from '@/auth/elysia-plugin'
import { createAuthMacro } from '@/auth/elysia-plugin'
import { createStandaloneLogger } from '@/config/logger'
import type { Settings } from '@/config/settings'
import { safeErrorHandler } from '@/middleware/error-handling'
import { Elysia } from 'elysia'
import {
fetchAuthorizeUrl,
fetchGithubStatus,
type AuthorizeUrlResult,
type BrokerGithubOptions,
type GithubStatusResult,
} from './github'

export type GithubAuthorizeUrlDto =
| { configured: false }
| { configured: true; status: 'ok'; url: string }
| { configured: true; status: 'disabled' }
| { configured: true; status: 'failed' }

export type GithubStatusDto =
| { configured: false }
| { configured: true; status: 'ok'; connected: boolean }
| { configured: true; status: 'disabled' }
| { configured: true; status: 'failed' }

/** Injectable seams so the route handlers are unit-testable without the network. */
export type CodingAgentGithubDeps = {
fetchFn?: typeof fetch
fetchAuthorizeUrlFn?: (opts: BrokerGithubOptions, userId: string) => Promise<AuthorizeUrlResult>
fetchGithubStatusFn?: (opts: BrokerGithubOptions, userId: string) => Promise<GithubStatusResult>
}

/**
* Mount the GitHub connect/status routes under the `/coding-agent` prefix.
* Returns 200 with a discriminated DTO in all reachable cases (broker
* unconfigured, disabled, failed, ok) so the assistant tool can phrase a useful
* message rather than surfacing a raw HTTP error. The service token and broker
* error bodies are never logged or returned.
*/
export const createCodingAgentGithubRoutes = (settings: Settings, auth: Auth, deps?: CodingAgentGithubDeps) => {
const log = createStandaloneLogger(settings)
const fetchFn = deps?.fetchFn ?? globalThis.fetch
const authorizeUrl = deps?.fetchAuthorizeUrlFn ?? fetchAuthorizeUrl
const githubStatus = deps?.fetchGithubStatusFn ?? fetchGithubStatus

const brokerConfigured = (): boolean => settings.codingAgentBrokerUrl.trim().length > 0
const brokerOpts = (): BrokerGithubOptions => ({
brokerUrl: settings.codingAgentBrokerUrl,
serviceToken: settings.codingAgentServiceToken,
fetchFn,
})

return new Elysia({ name: 'coding-agent-github-routes', prefix: '/coding-agent/github' })
.onError(safeErrorHandler)
.use(createAuthMacro(auth))
.guard({ auth: true }, (g) =>
g
.get('/authorize-url', async ({ user }): Promise<GithubAuthorizeUrlDto> => {
if (!brokerConfigured()) {
return { configured: false }
}
const result = await authorizeUrl(brokerOpts(), user.id)
if (result.status === 'failed') {
log.error({ userId: user.id, reason: result.reason }, 'coding-agent: authorize-url failed')
return { configured: true, status: 'failed' }
}
if (result.status === 'disabled') {
return { configured: true, status: 'disabled' }
}
return { configured: true, status: 'ok', url: result.url }
})
.get('/status', async ({ user }): Promise<GithubStatusDto> => {
if (!brokerConfigured()) {
return { configured: false }
}
const result = await githubStatus(brokerOpts(), user.id)
if (result.status === 'failed') {
log.error({ userId: user.id, reason: result.reason }, 'coding-agent: github status failed')
return { configured: true, status: 'failed' }
}
if (result.status === 'disabled') {
return { configured: true, status: 'disabled' }
}
return { configured: true, status: 'ok', connected: result.connected }
}),
)
}
138 changes: 138 additions & 0 deletions backend/src/coding-agent/github.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { describe, expect, it } from 'bun:test'
import { fetchAuthorizeUrl, fetchGithubStatus } from './github'

type Captured = { url: string; method?: string; headers: Record<string, string> }
type Step = number | 'throw' | 'timeout' | { status: number; body: string }

/** Programmable fetch: each step is an HTTP status (200 body inferred), an object
* with an explicit body, or 'throw'/'timeout' to reject. */
const makeFetch = (steps: Step[]) => {
const calls: Captured[] = []
let i = 0
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
const headers = Object.fromEntries(new Headers(init?.headers).entries())
calls.push({ url: String(url), method: init?.method, headers })
const step = steps[Math.min(i, steps.length - 1)]
i += 1
if (step === 'throw') {
throw new Error('network down')
}
if (step === 'timeout') {
const err = new Error('timed out')
err.name = 'TimeoutError'
throw err
}
if (typeof step === 'object') {
return new Response(step.body, { status: step.status })
}
return new Response(step === 200 ? '{}' : '', { status: step })
}) as unknown as typeof fetch
return { fetchFn, calls }
}

const opts = (fetchFn: typeof fetch, extra: Record<string, unknown> = {}) => ({
brokerUrl: 'https://broker.example/',
serviceToken: 'svc-token',
fetchFn,
timeoutMs: 100,
maxAttempts: 2,
...extra,
})

describe('fetchAuthorizeUrl', () => {
it('GETs /github/authorize-url with the service token + user id, returns the url', async () => {
const { fetchFn, calls } = makeFetch([
{ status: 200, body: JSON.stringify({ url: 'https://github.com/login/oauth/authorize?x=1' }) },
])
const result = await fetchAuthorizeUrl(opts(fetchFn), 'user-alice')

expect(result).toEqual({ status: 'ok', url: 'https://github.com/login/oauth/authorize?x=1' })
expect(calls).toHaveLength(1)
expect(calls[0].method).toBe('GET')
expect(calls[0].url).toBe('https://broker.example/github/authorize-url') // trailing slash normalized
expect(calls[0].headers.authorization).toBe('Bearer svc-token')
expect(calls[0].headers['x-tb-user-id']).toBe('user-alice')
})

it('maps 501 to disabled without retrying', async () => {
const { fetchFn, calls } = makeFetch([501, 200])
expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'disabled' })
expect(calls).toHaveLength(1)
})

it('maps a non-retryable 4xx to failed without retrying', async () => {
const { fetchFn, calls } = makeFetch([403, 200])
expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker 403' })
expect(calls).toHaveLength(1)
})

it('retries a 5xx then succeeds', async () => {
const { fetchFn, calls } = makeFetch([503, { status: 200, body: JSON.stringify({ url: 'https://x' }) }])
expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'ok', url: 'https://x' })
expect(calls).toHaveLength(2)
})

it('treats a missing/empty url in the body as failed (bad body)', async () => {
const { fetchFn } = makeFetch([{ status: 200, body: JSON.stringify({ url: '' }) }])
expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker bad body' })
})

it('treats invalid JSON as failed (bad body)', async () => {
const { fetchFn } = makeFetch([{ status: 200, body: 'not-json' }])
expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker bad body' })
})

it('maps a timeout to failed', async () => {
const { fetchFn } = makeFetch(['timeout'])
expect(await fetchAuthorizeUrl(opts(fetchFn, { maxAttempts: 1 }), 'u1')).toEqual({
status: 'failed',
reason: 'broker timeout',
})
})

it('retries a network error then fails as unreachable', async () => {
const { fetchFn, calls } = makeFetch(['throw', 'throw'])
expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker unreachable' })
expect(calls).toHaveLength(2)
})

it('never carries an end-user token (only the service token)', async () => {
const { fetchFn, calls } = makeFetch([{ status: 200, body: JSON.stringify({ url: 'https://x' }) }])
await fetchAuthorizeUrl(opts(fetchFn), 'u1')
expect(calls[0].headers.authorization).toBe('Bearer svc-token')
})
})

describe('fetchGithubStatus', () => {
it('GETs /github/status and returns connected:true', async () => {
const { fetchFn, calls } = makeFetch([{ status: 200, body: JSON.stringify({ connected: true }) }])
expect(await fetchGithubStatus(opts(fetchFn), 'u1')).toEqual({ status: 'ok', connected: true })
expect(calls[0].url).toBe('https://broker.example/github/status')
expect(calls[0].method).toBe('GET')
})

it('returns connected:false', async () => {
const { fetchFn } = makeFetch([{ status: 200, body: JSON.stringify({ connected: false }) }])
expect(await fetchGithubStatus(opts(fetchFn), 'u1')).toEqual({ status: 'ok', connected: false })
})

it('maps 501 to disabled', async () => {
const { fetchFn } = makeFetch([501])
expect(await fetchGithubStatus(opts(fetchFn), 'u1')).toEqual({ status: 'disabled' })
})

it('treats a non-boolean connected as failed (bad body)', async () => {
const { fetchFn } = makeFetch([{ status: 200, body: JSON.stringify({ connected: 'yes' }) }])
expect(await fetchGithubStatus(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker bad body' })
})

it('maps a 5xx after retries to failed', async () => {
const { fetchFn, calls } = makeFetch([500, 500])
expect(await fetchGithubStatus(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker 500' })
expect(calls).toHaveLength(2)
})
})
Loading
Loading