forgejo-mcp-broker/cmd/broker/e2e_test.go

322 lines
11 KiB
Go
Raw Normal View History

feat(broker): wire OAuth + MCP session glue into main; e2e test (forgejo-mcp-broker-q6n) cmd/broker/main.go now composes every phase-2-5 component into a live binary: /healthz → internal/httpserver /oauth/* → internal/oauth.Server.Handler() /.well-known → internal/oauth.Server.Handler() /mcp → oauth.Authenticator.RequireBearer over session.Registry.Handler() The SpawnFunc passed to the registry composes supervisor + bridge: each new MCP session forks `forgejo-mcp --transport stdio` with the user's upstream token in env, wraps stdio with a bridge, and returns the bridge's HandleSSE as the per-session http.Handler. The reaper is wired with a refresh callback that calls forgejo.Client.Refresh and persists rotated tokens back to access_tokens before the rotator swaps the session's child. cmd/broker/e2e_test.go is the gating local validation: builds the binary, builds forgejo-mcp from the sibling repo (skipped if absent), stands up a fake Forgejo, runs the broker, and walks register → authorize → callback → token → /mcp initialize → tools/list. This catches: - any component left unwired - the subprocess-context bug fixed in this commit (using a request context in supervisor.Start kills the child when the request that minted it returns; the fix is a long-lived childCtx) - the happy-path Mcp-Session-Id mint+reuse cycle that unit tests can't exercise without a real subprocess docs/phase7-findings.md documents both the local automated validation (this test) and the manual Claude.ai-side checklist (OAuth completes, tool discovery, tool invocation, session reuse, idle reap, mid-session token refresh, revocation). The Claude.ai half is fundamentally manual and stays that way; the automated test catches the broker bugs that would otherwise hide behind operator setup mistakes. Closes forgejo-mcp-broker-q6n. Phase 7 — and the project's primary implementation track — complete. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:55:18 +02:00
package main_test
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
)
// TestE2E_FullOAuthAndMCPFlow exercises every wired component end-to-end:
//
// 1. Build the broker binary (already done in TestMain).
// 2. Build forgejo-mcp from sibling source so the broker can spawn it.
// 3. Stand up a fake Forgejo (httptest.Server) covering the OAuth and
// OIDC endpoints plus the API surface forgejo-mcp probes at startup.
// 4. Run the broker with all that wired up.
// 5. Walk through: register → authorize → callback → token →
// /mcp (initialize via Bearer token) → tools/list.
//
// Skipped under -short and when the sibling forgejo-mcp source isn't
// present. This test is the closest local stand-in for the manual
// Claude.ai validation in docs/phase7-findings.md.
func TestE2E_FullOAuthAndMCPFlow(t *testing.T) {
if testing.Short() {
t.Skip("end-to-end test (~5s); rerun without -short")
}
forgejoMCPBin := buildForgejoMCPSibling(t)
fakeForgejo := startFakeForgejo(t)
storePath := filepath.Join(t.TempDir(), "broker.db")
listenAddr := freePort(t)
publicURL := "http://" + listenAddr
cmd := exec.Command(binPath,
"--public-url", publicURL,
"--forgejo-url", fakeForgejo.URL,
"--forgejo-oauth-client-id", "broker-app",
"--forgejo-oauth-client-secret", "broker-secret",
"--forgejo-mcp-binary", forgejoMCPBin,
"--listen", listenAddr,
"--store-path", storePath,
"--debug",
)
cmd.Env = []string{"PATH=" + os.Getenv("PATH")}
stderr := &captureBuffer{}
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start broker: %v", err)
}
defer func() {
if cmd.Process != nil {
_ = cmd.Process.Signal(syscall.SIGTERM)
_, _ = cmd.Process.Wait()
}
}()
waitListening(t, listenAddr, 5*time.Second)
// Step 1 — register a client.
clientID := registerClient(t, publicURL, "https://app.example.com/cb")
// Step 2 — authorize. We use a no-redirect HTTP client so we can
// inspect the redirect Location and pick out the Forgejo state.
verifier := "a-pkce-verifier-thats-quite-long-12345678"
challenge := pkceChallenge(verifier)
authQ := url.Values{
"response_type": {"code"},
"client_id": {clientID},
"redirect_uri": {"https://app.example.com/cb"},
"state": {"client-csrf"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
resp := getNoRedirect(t, publicURL+"/oauth/authorize?"+authQ.Encode())
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("authorize: status = %d, want 302", resp.StatusCode)
}
upstream, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatalf("parse Location: %v", err)
}
forgejoState := upstream.Query().Get("state")
if forgejoState == "" {
t.Fatalf("authorize did not produce a Forgejo state: %s", upstream)
}
// Step 3 — fake Forgejo would have authenticated the user and
// redirected to /oauth/callback. Simulate that.
cb := getNoRedirect(t, publicURL+"/oauth/callback?code=upstream-code&state="+forgejoState)
cb.Body.Close()
if cb.StatusCode != http.StatusFound {
t.Fatalf("callback: status = %d, want 302", cb.StatusCode)
}
cbLoc, _ := url.Parse(cb.Header.Get("Location"))
brokerCode := cbLoc.Query().Get("code")
if brokerCode == "" {
t.Fatalf("callback did not return broker code: %s", cbLoc)
}
// Step 4 — exchange broker code for access + refresh tokens.
tokForm := url.Values{
"grant_type": {"authorization_code"},
"code": {brokerCode},
"client_id": {clientID},
"redirect_uri": {"https://app.example.com/cb"},
"code_verifier": {verifier},
}
tokResp, err := http.PostForm(publicURL+"/oauth/token", tokForm)
if err != nil {
t.Fatalf("token: %v", err)
}
defer tokResp.Body.Close()
if tokResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(tokResp.Body)
t.Fatalf("token status = %d: %s", tokResp.StatusCode, body)
}
var tokens struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(tokResp.Body).Decode(&tokens); err != nil {
t.Fatalf("decode token: %v", err)
}
if tokens.AccessToken == "" {
t.Fatalf("token response missing access_token")
}
// Step 5 — call /mcp with the bearer token. First the MCP `initialize`
// handshake; the broker spawns a forgejo-mcp child for this session.
initBody := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e","version":"1"}}}`
initReq, _ := http.NewRequest(http.MethodPost, publicURL+"/mcp", strings.NewReader(initBody))
initReq.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
initReq.Header.Set("Content-Type", "application/json")
initResp, err := http.DefaultClient.Do(initReq)
if err != nil {
t.Fatalf("initialize: %v", err)
}
defer initResp.Body.Close()
if initResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(initResp.Body)
t.Fatalf("initialize status = %d: %s\n\nbroker stderr:\n%s", initResp.StatusCode, body, stderr.String())
}
sid := initResp.Header.Get("Mcp-Session-Id")
if sid == "" {
t.Fatalf("initialize did not return Mcp-Session-Id")
}
body, _ := io.ReadAll(initResp.Body)
if !strings.Contains(string(body), `"protocolVersion"`) {
t.Errorf("initialize response missing protocolVersion: %s", body)
}
// Step 6 — tools/list with the same sid. Different RPC, same forgejo-mcp child.
listBody := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`
listReq, _ := http.NewRequest(http.MethodPost, publicURL+"/mcp", strings.NewReader(listBody))
listReq.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
listReq.Header.Set("Content-Type", "application/json")
listReq.Header.Set("Mcp-Session-Id", sid)
listResp, err := http.DefaultClient.Do(listReq)
if err != nil {
t.Fatalf("tools/list: %v", err)
}
defer listResp.Body.Close()
listBytes, _ := io.ReadAll(listResp.Body)
if listResp.StatusCode != http.StatusOK {
t.Fatalf("tools/list status = %d: %s\n\nbroker stderr:\n%s",
listResp.StatusCode, listBytes, stderr.String())
}
if !strings.Contains(string(listBytes), "get_forgejo_mcp_server_version") {
t.Errorf("tools/list missing expected tool. Body: %s", listBytes)
}
// Step 7 — discovery surface returns issuer-rooted URLs derived from
// --public-url, not from the test server's address.
disc, err := http.Get(publicURL + "/.well-known/oauth-authorization-server")
if err != nil {
t.Fatalf("discovery: %v", err)
}
defer disc.Body.Close()
var md map[string]any
if err := json.NewDecoder(disc.Body).Decode(&md); err != nil {
t.Fatalf("decode discovery: %v", err)
}
if md["issuer"] != publicURL {
t.Errorf("issuer = %v, want %s", md["issuer"], publicURL)
}
}
// captureBuffer is a thread-safe buffer for collecting child stderr.
type captureBuffer struct {
mu sync.Mutex
buf strings.Builder
}
func (c *captureBuffer) Write(p []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.buf.Write(p)
}
func (c *captureBuffer) String() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.buf.String()
}
func registerClient(t *testing.T, baseURL, redirectURI string) string {
t.Helper()
body, _ := json.Marshal(map[string]any{
"redirect_uris": []string{redirectURI},
"client_name": "e2e-test",
})
resp, err := http.Post(baseURL+"/oauth/register", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatalf("register: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("register status = %d: %s", resp.StatusCode, b)
}
var r struct {
ClientID string `json:"client_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("decode register: %v", err)
}
return r.ClientID
}
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
var noRedirectClient = &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
func getNoRedirect(t *testing.T, url string) *http.Response {
t.Helper()
resp, err := noRedirectClient.Get(url)
if err != nil {
t.Fatalf("get %s: %v", url, err)
}
return resp
}
// startFakeForgejo stands up the Forgejo API surface we need: SDK
// version probe, OIDC userinfo, OAuth token endpoint, plus an /api/v1/user
// for forgejo-mcp's own startup probe.
func startFakeForgejo(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"version":"11.0.0"}`)
})
mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":1,"login":"e2e-user","username":"e2e-user","full_name":"E2E"}`)
})
mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w,
`{"access_token":"fj-access-token","refresh_token":"fj-refresh-token","token_type":"bearer","expires_in":3600}`)
})
mux.HandleFunc("/login/oauth/userinfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"sub":"42","preferred_username":"e2e-user","name":"E2E User"}`)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
t.Logf("fake forgejo: unexpected probe %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// buildForgejoMCPSibling locates / builds the forgejo-mcp binary the
// broker will spawn. Same search order as internal/bridge's integration
// test.
func buildForgejoMCPSibling(t *testing.T) string {
t.Helper()
if p := os.Getenv("FORGEJO_MCP_BIN"); p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
t.Skipf("FORGEJO_MCP_BIN=%q does not exist", p)
}
if abs, err := filepath.Abs("../../../forgejo-mcp/forgejo-mcp"); err == nil {
if _, err := os.Stat(abs); err == nil {
return abs
}
}
if abs, err := filepath.Abs("../../../forgejo-mcp"); err == nil {
if _, err := os.Stat(filepath.Join(abs, "main.go")); err == nil {
bin := filepath.Join(t.TempDir(), "forgejo-mcp")
build := exec.Command("go", "build", "-o", bin, ".")
build.Dir = abs
build.Env = os.Environ()
out, err := build.CombinedOutput()
if err != nil {
t.Skipf("go build of sibling forgejo-mcp failed: %v\n%s", err, out)
}
return bin
}
}
t.Skip("forgejo-mcp binary not found: set $FORGEJO_MCP_BIN or place a sibling repo at ../forgejo-mcp")
return ""
}