Adds internal/forgejo: a stateless OAuth 2.1 client for upstream Forgejo.
Covers what the broker AS needs:
- AuthorizeURL: builds the user-agent redirect to /login/oauth/authorize
- ExchangeCode: code → access+refresh tokens (PKCE verifier included)
- Refresh: refresh_token grant (Forgejo rotates the refresh token)
- FetchUserInfo: OIDC userinfo claims (sub, preferred_username, etc.)
OAuth errors come back as a structured *forgejo.Error so the AS can
distinguish "user must re-authenticate" (invalid_grant) from "transient
network problem" via errors.As. Forgejo doesn't currently expose a token
revocation endpoint, so revocation lives in the broker's own store —
upstream tokens expire naturally.
Defaults:
- 30s HTTP timeout (Forgejo OAuth is sub-second when healthy)
- User-Agent "fjmcp-broker" if not overridden
- 64 KiB cap on response bodies (these endpoints return ~kilobytes)
Tests: 95.1% coverage. httptest.Server fake Forgejo exercises every
public method, every error shape (OAuth-formatted, plain {"message":...},
malformed JSON, missing required fields, network failure), and verifies
form params hit the wire as expected.
Closes forgejo-mcp-broker-b9i.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
390 lines
12 KiB
Go
390 lines
12 KiB
Go
package forgejo_test
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/forgejo"
|
|
)
|
|
|
|
// newTestClient returns a Client pointed at the given test server URL with
|
|
// well-known credentials. Callers can override individual ClientConfig
|
|
// fields by setting them on the returned config before calling NewClient
|
|
// themselves; this helper just keeps the boilerplate down.
|
|
func newTestClient(t *testing.T, baseURL string) *forgejo.Client {
|
|
t.Helper()
|
|
c, err := forgejo.NewClient(forgejo.ClientConfig{
|
|
BaseURL: baseURL,
|
|
ClientID: "test-client-id",
|
|
ClientSecret: "test-client-secret",
|
|
UserAgent: "test-broker",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func TestNewClient_ValidationErrors(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cfg forgejo.ClientConfig
|
|
want string
|
|
}{
|
|
{"no_base_url", forgejo.ClientConfig{ClientID: "id", ClientSecret: "s"}, "BaseURL"},
|
|
{"no_client_id", forgejo.ClientConfig{BaseURL: "https://x", ClientSecret: "s"}, "ClientID"},
|
|
{"no_client_secret", forgejo.ClientConfig{BaseURL: "https://x", ClientID: "id"}, "ClientSecret"},
|
|
{"bad_scheme", forgejo.ClientConfig{BaseURL: "ftp://x", ClientID: "id", ClientSecret: "s"}, "http(s)"},
|
|
{"no_host", forgejo.ClientConfig{BaseURL: "https://", ClientID: "id", ClientSecret: "s"}, "missing host"},
|
|
{"unparseable_url", forgejo.ClientConfig{BaseURL: "://nope", ClientID: "id", ClientSecret: "s"}, "parse BaseURL"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := forgejo.NewClient(tc.cfg)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("want error containing %q, got %v", tc.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewClient_DefaultsApplied(t *testing.T) {
|
|
c, err := forgejo.NewClient(forgejo.ClientConfig{
|
|
BaseURL: "https://forgejo.example.com", ClientID: "id", ClientSecret: "s",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
// Smoke-test by building a URL — defaults don't have a public getter.
|
|
u := c.AuthorizeURL(forgejo.AuthorizeURLOptions{
|
|
RedirectURI: "https://x/cb", State: "st", CodeChallenge: "cc", CodeChallengeMethod: "S256",
|
|
})
|
|
if !strings.HasPrefix(u, "https://forgejo.example.com/login/oauth/authorize?") {
|
|
t.Errorf("authorize URL prefix wrong: %s", u)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeURL_AllParamsPresent(t *testing.T) {
|
|
c := newTestClient(t, "https://forgejo.example.com")
|
|
out := c.AuthorizeURL(forgejo.AuthorizeURLOptions{
|
|
RedirectURI: "https://broker.example.com/oauth/callback",
|
|
State: "csrf-token",
|
|
Scopes: "read:user write:repository",
|
|
CodeChallenge: "challenge-string",
|
|
CodeChallengeMethod: "S256",
|
|
})
|
|
u, err := url.Parse(out)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
q := u.Query()
|
|
want := map[string]string{
|
|
"response_type": "code",
|
|
"client_id": "test-client-id",
|
|
"redirect_uri": "https://broker.example.com/oauth/callback",
|
|
"state": "csrf-token",
|
|
"scope": "read:user write:repository",
|
|
"code_challenge": "challenge-string",
|
|
"code_challenge_method": "S256",
|
|
}
|
|
for k, v := range want {
|
|
if q.Get(k) != v {
|
|
t.Errorf("query[%q] = %q, want %q", k, q.Get(k), v)
|
|
}
|
|
}
|
|
if u.Path != "/login/oauth/authorize" {
|
|
t.Errorf("path = %q, want /login/oauth/authorize", u.Path)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeURL_OmitsScopeWhenEmpty(t *testing.T) {
|
|
c := newTestClient(t, "https://forgejo.example.com")
|
|
out := c.AuthorizeURL(forgejo.AuthorizeURLOptions{
|
|
RedirectURI: "https://x/cb", State: "s", CodeChallenge: "c", CodeChallengeMethod: "S256",
|
|
})
|
|
u, _ := url.Parse(out)
|
|
if u.Query().Has("scope") {
|
|
t.Errorf("scope should not appear when empty: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeURL_BaseWithTrailingSlash(t *testing.T) {
|
|
// Trailing slash on BaseURL must not cause a double-slash path.
|
|
c := newTestClient(t, "https://forgejo.example.com/")
|
|
out := c.AuthorizeURL(forgejo.AuthorizeURLOptions{
|
|
RedirectURI: "https://x/cb", State: "s", CodeChallenge: "c", CodeChallengeMethod: "S256",
|
|
})
|
|
if strings.Contains(out, "com//login") {
|
|
t.Errorf("double slash in URL: %s", out)
|
|
}
|
|
}
|
|
|
|
// fakeForgejo wraps an httptest.Server with handler injection points so each
|
|
// test can shape the response without rewriting the boilerplate.
|
|
type fakeForgejo struct {
|
|
t *testing.T
|
|
server *httptest.Server
|
|
tokenStatus int
|
|
tokenBody string
|
|
userStatus int
|
|
userBody string
|
|
lastForm url.Values // populated after a token endpoint hit
|
|
lastAuth string // populated after a userinfo hit
|
|
}
|
|
|
|
func newFakeForgejo(t *testing.T) *fakeForgejo {
|
|
t.Helper()
|
|
f := &fakeForgejo{
|
|
t: t,
|
|
tokenStatus: http.StatusOK,
|
|
userStatus: http.StatusOK,
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
form, _ := url.ParseQuery(string(body))
|
|
f.lastForm = form
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(f.tokenStatus)
|
|
_, _ = io.WriteString(w, f.tokenBody)
|
|
})
|
|
mux.HandleFunc("/login/oauth/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
|
f.lastAuth = r.Header.Get("Authorization")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(f.userStatus)
|
|
_, _ = io.WriteString(w, f.userBody)
|
|
})
|
|
f.server = httptest.NewServer(mux)
|
|
t.Cleanup(f.server.Close)
|
|
return f
|
|
}
|
|
|
|
func TestExchangeCode_Success(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.tokenBody = `{"access_token":"a","refresh_token":"r","token_type":"bearer","expires_in":3600,"scope":"read:user"}`
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
tok, err := c.ExchangeCode(t.Context(), "the-code", "the-verifier", "https://broker.example.com/oauth/callback")
|
|
if err != nil {
|
|
t.Fatalf("ExchangeCode: %v", err)
|
|
}
|
|
if tok.AccessToken != "a" || tok.RefreshToken != "r" || tok.ExpiresIn != 3600 {
|
|
t.Errorf("token mismatch: %+v", tok)
|
|
}
|
|
// Verify the form params Forgejo received.
|
|
expected := map[string]string{
|
|
"grant_type": "authorization_code",
|
|
"code": "the-code",
|
|
"code_verifier": "the-verifier",
|
|
"redirect_uri": "https://broker.example.com/oauth/callback",
|
|
"client_id": "test-client-id",
|
|
"client_secret": "test-client-secret",
|
|
}
|
|
for k, v := range expected {
|
|
if got := f.lastForm.Get(k); got != v {
|
|
t.Errorf("form[%q] = %q, want %q", k, got, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExchangeCode_OAuthError(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.tokenStatus = http.StatusBadRequest
|
|
f.tokenBody = `{"error":"invalid_grant","error_description":"code expired"}`
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
_, err := c.ExchangeCode(t.Context(), "x", "v", "https://x/cb")
|
|
var e *forgejo.Error
|
|
if !errors.As(err, &e) {
|
|
t.Fatalf("want *forgejo.Error, got %T: %v", err, err)
|
|
}
|
|
if e.Code != "invalid_grant" {
|
|
t.Errorf("Code = %q, want invalid_grant", e.Code)
|
|
}
|
|
if e.HTTPStatus != http.StatusBadRequest {
|
|
t.Errorf("HTTPStatus = %d, want %d", e.HTTPStatus, http.StatusBadRequest)
|
|
}
|
|
if !strings.Contains(e.Error(), "expired") {
|
|
t.Errorf("Error string missing description: %v", e)
|
|
}
|
|
}
|
|
|
|
func TestExchangeCode_OAuthErrorWithoutDescription(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.tokenStatus = http.StatusBadRequest
|
|
f.tokenBody = `{"error":"invalid_request"}`
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
_, err := c.ExchangeCode(t.Context(), "x", "v", "https://x/cb")
|
|
var e *forgejo.Error
|
|
if !errors.As(err, &e) || strings.Contains(e.Error(), ":") == false {
|
|
t.Errorf("expected formatted error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExchangeCode_5xx_NoBody(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.tokenStatus = http.StatusInternalServerError
|
|
f.tokenBody = "<html>oops</html>"
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
_, err := c.ExchangeCode(t.Context(), "x", "v", "https://x/cb")
|
|
var e *forgejo.Error
|
|
if !errors.As(err, &e) {
|
|
t.Fatalf("want *forgejo.Error, got %v", err)
|
|
}
|
|
if e.HTTPStatus != http.StatusInternalServerError {
|
|
t.Errorf("HTTPStatus = %d", e.HTTPStatus)
|
|
}
|
|
}
|
|
|
|
func TestExchangeCode_MalformedJSON(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.tokenStatus = http.StatusOK
|
|
f.tokenBody = "not json"
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
_, err := c.ExchangeCode(t.Context(), "x", "v", "https://x/cb")
|
|
if err == nil || !strings.Contains(err.Error(), "decode") {
|
|
t.Errorf("want decode error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExchangeCode_MissingAccessToken(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.tokenBody = `{"token_type":"bearer","expires_in":1}`
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
_, err := c.ExchangeCode(t.Context(), "x", "v", "https://x/cb")
|
|
if err == nil || !strings.Contains(err.Error(), "access_token") {
|
|
t.Errorf("want missing access_token error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExchangeCode_NetworkError(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
c := newTestClient(t, f.server.URL)
|
|
f.server.Close() // force network error on next call
|
|
|
|
_, err := c.ExchangeCode(t.Context(), "x", "v", "https://x/cb")
|
|
if err == nil {
|
|
t.Fatal("expected network error")
|
|
}
|
|
// Should not be a structured *forgejo.Error — those are for upstream
|
|
// rejections, not transport failures.
|
|
var e *forgejo.Error
|
|
if errors.As(err, &e) {
|
|
t.Errorf("network errors must not surface as *forgejo.Error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRefresh_Success(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.tokenBody = `{"access_token":"a2","refresh_token":"r2","token_type":"bearer","expires_in":3600}`
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
tok, err := c.Refresh(t.Context(), "old-refresh-token")
|
|
if err != nil {
|
|
t.Fatalf("Refresh: %v", err)
|
|
}
|
|
if tok.AccessToken != "a2" || tok.RefreshToken != "r2" {
|
|
t.Errorf("token mismatch: %+v", tok)
|
|
}
|
|
if f.lastForm.Get("grant_type") != "refresh_token" {
|
|
t.Errorf("grant_type = %q, want refresh_token", f.lastForm.Get("grant_type"))
|
|
}
|
|
if f.lastForm.Get("refresh_token") != "old-refresh-token" {
|
|
t.Errorf("refresh_token form param = %q", f.lastForm.Get("refresh_token"))
|
|
}
|
|
}
|
|
|
|
func TestRefresh_InvalidGrant(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.tokenStatus = http.StatusBadRequest
|
|
f.tokenBody = `{"error":"invalid_grant","error_description":"refresh token revoked"}`
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
_, err := c.Refresh(t.Context(), "stale")
|
|
var e *forgejo.Error
|
|
if !errors.As(err, &e) || e.Code != "invalid_grant" {
|
|
t.Errorf("want invalid_grant, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchUserInfo_Success(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.userBody = `{"sub":"42","preferred_username":"alice","name":"Alice Bee","email":"alice@example.com"}`
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
ui, err := c.FetchUserInfo(t.Context(), "the-access-token")
|
|
if err != nil {
|
|
t.Fatalf("FetchUserInfo: %v", err)
|
|
}
|
|
if ui.Sub != "42" || ui.PreferredUsername != "alice" {
|
|
t.Errorf("user mismatch: %+v", ui)
|
|
}
|
|
if f.lastAuth != "Bearer the-access-token" {
|
|
t.Errorf("Authorization header = %q", f.lastAuth)
|
|
}
|
|
}
|
|
|
|
func TestFetchUserInfo_OAuthError(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.userStatus = http.StatusUnauthorized
|
|
f.userBody = `{"error":"invalid_token","error_description":"expired"}`
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
_, err := c.FetchUserInfo(t.Context(), "x")
|
|
var e *forgejo.Error
|
|
if !errors.As(err, &e) || e.Code != "invalid_token" {
|
|
t.Errorf("want invalid_token, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchUserInfo_NonOAuthError(t *testing.T) {
|
|
// Some Forgejo versions return {"message": "..."} for 401 instead of the
|
|
// RFC 6749 oauth-error shape. Verify we still return a structured error.
|
|
f := newFakeForgejo(t)
|
|
f.userStatus = http.StatusUnauthorized
|
|
f.userBody = `{"message":"unauthenticated"}`
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
_, err := c.FetchUserInfo(t.Context(), "x")
|
|
var e *forgejo.Error
|
|
if !errors.As(err, &e) {
|
|
t.Fatalf("want *forgejo.Error, got %v", err)
|
|
}
|
|
if e.Code != "userinfo_failed" {
|
|
t.Errorf("Code = %q, want userinfo_failed", e.Code)
|
|
}
|
|
if e.HTTPStatus != http.StatusUnauthorized {
|
|
t.Errorf("HTTPStatus = %d", e.HTTPStatus)
|
|
}
|
|
}
|
|
|
|
func TestFetchUserInfo_MissingSub(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.userBody = `{"preferred_username":"alice"}`
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
_, err := c.FetchUserInfo(t.Context(), "x")
|
|
if err == nil || !strings.Contains(err.Error(), "sub") {
|
|
t.Errorf("want missing-sub error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchUserInfo_MalformedJSON(t *testing.T) {
|
|
f := newFakeForgejo(t)
|
|
f.userBody = "not json"
|
|
c := newTestClient(t, f.server.URL)
|
|
|
|
_, err := c.FetchUserInfo(t.Context(), "x")
|
|
if err == nil || !strings.Contains(err.Error(), "decode") {
|
|
t.Errorf("want decode error, got %v", err)
|
|
}
|
|
}
|