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 "" }