package main import ( "context" "io" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" "sourcecraft.dev/bigbes/sr-ht-ecore/csrf" "sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest" "sourcecraft.dev/bigbes/sr-ht-ecore/instconf" ) // TestMain seeds the process-global crypto state from sr-ht-ecore's fixed test // keyset, so that the bearer-token HMAC works in process and a working token can // be forged here rather than mocked. No network, no Postgres. func TestMain(m *testing.M) { ecoretest.InitCrypto() os.Exit(m.Run()) } // loadConf builds an ini.File directly from a literal, bypassing // config.LoadConfig so the tests need no config.ini on disk and no // internal-ipnet parsing. func loadConf(t *testing.T, body string) ini.File { t.Helper() conf, err := ini.Load(strings.NewReader(body)) if err != nil { t.Fatalf("ini.Load: %v", err) } return conf } func TestResolveSettingsDefaults(t *testing.T) { conf := loadConf(t, `[dolt.sr.ht] origin=https://dolt.example.org connection-string=postgres://u@localhost/d?sslmode=disable `) got, err := resolveSettings(conf) if err != nil { t.Fatalf("resolveSettings: %v", err) } if got.connString != "postgres://u@localhost/d?sslmode=disable" { t.Errorf("connString = %q", got.connString) } if got.httpHost != "dolt.example.org" { t.Errorf("httpHost = %q, want dolt.example.org", got.httpHost) } if got.reposRoot != defaultReposRoot { t.Errorf("reposRoot = %q, want %q", got.reposRoot, defaultReposRoot) } if got.staticDir != defaultStaticDir { t.Errorf("staticDir = %q, want %q", got.staticDir, defaultStaticDir) } if got.remotesapiAddr != defaultRemotesapiAddr { t.Errorf("remotesapiAddr = %q, want %q", got.remotesapiAddr, defaultRemotesapiAddr) } if got.credsapiAddr != defaultCredsapiAddr { t.Errorf("credsapiAddr = %q, want %q", got.credsapiAddr, defaultCredsapiAddr) } } func TestResolveSettingsOverrides(t *testing.T) { conf := loadConf(t, `[dolt.sr.ht] origin=https://dolt.example.org:8443 connection-string=postgres://u@localhost/d repos=/srv/dolt static-dir=/usr/share/sourcehut/dolt.sr.ht/static remotesapi-listen=0.0.0.0:6306 credsapi-listen=0.0.0.0:6308 `) got, err := resolveSettings(conf) if err != nil { t.Fatalf("resolveSettings: %v", err) } if got.reposRoot != "/srv/dolt" { t.Errorf("reposRoot = %q", got.reposRoot) } if got.staticDir != "/usr/share/sourcehut/dolt.sr.ht/static" { t.Errorf("staticDir = %q", got.staticDir) } if got.remotesapiAddr != "0.0.0.0:6306" { t.Errorf("remotesapiAddr = %q", got.remotesapiAddr) } if got.credsapiAddr != "0.0.0.0:6308" { t.Errorf("credsapiAddr = %q", got.credsapiAddr) } // The port is part of the sealed-URL authority and is preserved. if got.httpHost != "dolt.example.org:8443" { t.Errorf("httpHost = %q, want dolt.example.org:8443", got.httpHost) } } func TestResolveSettingsMissingConnString(t *testing.T) { conf := loadConf(t, `[dolt.sr.ht] origin=https://dolt.example.org `) _, err := resolveSettings(conf) require.Error(t, err) assert.ErrorIs(t, err, instconf.ErrIncompleteConfig) assert.Contains(t, err.Error(), "connection-string") } func TestResolveSettingsMissingOrigin(t *testing.T) { conf := loadConf(t, `[dolt.sr.ht] connection-string=postgres://u@localhost/d `) _, err := resolveSettings(conf) require.Error(t, err) assert.ErrorIs(t, err, instconf.ErrIncompleteConfig) assert.Contains(t, err.Error(), "origin") } // TestResolveSettingsReportsEveryMissingKey is the reason the two checks became // one Require: an operator with an empty section gets both keys off one boot // rather than one key per restart. func TestResolveSettingsReportsEveryMissingKey(t *testing.T) { _, err := resolveSettings(loadConf(t, "[dolt.sr.ht]\n")) require.Error(t, err) assert.Contains(t, err.Error(), "connection-string") assert.Contains(t, err.Error(), "origin") } // TestResolveSettingsHostlessOrigin covers the gap Require cannot: a present, // non-blank origin that names no host. "dolt.example.org" without a scheme is a // URL path, and a service whose sealed chunk URLs and JWT audience are built // from an empty authority must refuse to start rather than guess a host. func TestResolveSettingsHostlessOrigin(t *testing.T) { conf := loadConf(t, `[dolt.sr.ht] connection-string=postgres://u@localhost/d origin=dolt.example.org `) _, err := resolveSettings(conf) require.Error(t, err) assert.Contains(t, err.Error(), "names no host") } // TestResolveSettingsTrimsTheOrigin: an origin written with a trailing slash is // the same origin. It used to reach url.Parse untouched, which happened to // answer the same host; the authority now comes off the canonical form, so the // two spellings cannot diverge for any other consumer either. func TestResolveSettingsTrimsTheOrigin(t *testing.T) { conf := loadConf(t, `[dolt.sr.ht] connection-string=postgres://u@localhost/d origin=https://dolt.example.org:8443/ `) got, err := resolveSettings(conf) require.NoError(t, err) assert.Equal(t, "dolt.example.org:8443", got.httpHost) } // TestResolveSettingsKeepsTheWholeOrigin: /mcp guards its Host header with the // origin as a URL (mcpsrv.New), while the sealed chunk URLs want the authority, // so the two live side by side rather than one being derived from the other at // the call site. Both come off the canonical spelling, so a trailing slash // cannot make them disagree. func TestResolveSettingsKeepsTheWholeOrigin(t *testing.T) { conf := loadConf(t, `[dolt.sr.ht] connection-string=postgres://u@localhost/d origin=https://dolt.example.org:8443/ `) got, err := resolveSettings(conf) require.NoError(t, err) assert.Equal(t, "https://dolt.example.org:8443", got.origin) assert.Equal(t, "dolt.example.org:8443", got.httpHost) } // --- the tokens.sr.ht plane ------------------------------------------------- // An instance that runs no tokens.sr.ht has no such section, and that is a // supported configuration rather than a broken one: the validator is absent, and // absent has to mean a genuinely nil interface. A *typed* nil would satisfy the // interface and panic on the first working token presented, which is the one // mistake authn.ResolveBearer's contract calls out by name. func TestNoTokensSectionYieldsNoValidator(t *testing.T) { v, err := newBearerValidator(ecoretest.Config(serviceName, ecoretest.Delete(tokensSection))) require.NoError(t, err) assert.Nil(t, v) assert.True(t, v == nil, "a typed nil would satisfy the interface and panic on the first working token") } // The revocation check is daemon to daemon, so the internal address wins where // an instance has one: that request sits on the hot path of a tool call, and // routing it out through the reverse proxy and back would put a public hop and // its TLS handshake between two containers on the same bridge. func TestTheTokensOriginIsReadInItsInternalForm(t *testing.T) { conf := ecoretest.Config(serviceName, ecoretest.Set(tokensSection, "origin", "https://tokens.example"), ecoretest.Set(tokensSection, "internal-origin", "http://tokens:5010")) v, err := newBearerValidator(conf) require.NoError(t, err) assert.NotNil(t, v) assert.Equal(t, "http://tokens:5010", tokensDescription(conf)) } // A section that exists and does not parse is an operator's typo, not a plane to // drop quietly: an instance that configured tokens.sr.ht meant to accept its // tokens, so the daemon says so and stops instead of starting with a surface // that refuses every working token for a reason nobody can see. func TestAnUnparseableTokensOriginStopsTheBoot(t *testing.T) { conf := ecoretest.Config(serviceName, ecoretest.Set(tokensSection, "origin", "tokens.example")) _, err := newBearerValidator(conf) require.Error(t, err) assert.Contains(t, err.Error(), "absolute http(s) URL") } // The startup line says which of the two states this daemon is in, so that "the // instance credential is not accepted here" is read off the journal rather than // deduced from the first refusal somebody reports. func TestTokensDescriptionNamesTheMissingKey(t *testing.T) { assert.Equal(t, "disabled ([tokens.sr.ht] origin is unset)", tokensDescription(ecoretest.Config(serviceName, ecoretest.Delete(tokensSection)))) assert.Equal(t, "https://tokens.example", tokensDescription(ecoretest.Config(serviceName))) } // --- the assembled listener ------------------------------------------------- // toolsList is one JSON-RPC message, the one an agent sends first after the // handshake. The transport runs stateless, so a single POST is a whole // conversation and no session has to be opened to ask this. const toolsList = `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}` // noStores stands in for the on-disk store lifecycle, which web.Register // requires and nothing in these tests reaches. It panics rather than returning a // zero value: a test that started creating stores would be testing something // else, and should fail loudly instead of quietly passing. type noStores struct{} func (noStores) InitStore(context.Context, string, string, string) error { panic("cmd/doltsrht: the boot tests must not touch the on-disk stores") } func (noStores) DeleteStore(context.Context, string, string) error { panic("cmd/doltsrht: the boot tests must not touch the on-disk stores") } func (noStores) Evict(string) error { panic("cmd/doltsrht: the boot tests must not touch the on-disk stores") } // bootConf is the instance config a booted daemon reads: the shared fixture plus // the one key of ours that has no default. func bootConf(t *testing.T, overrides ...func(ini.File)) ini.File { t.Helper() all := append([]func(ini.File){ ecoretest.Set(serviceName, "connection-string", "postgres://u@localhost/d?sslmode=disable"), }, overrides...) return ecoretest.Config(serviceName, all...) } // boot assembles this daemon's own listener — newMCPServer, then mountRoutes on // a chi Group, exactly as main does on the AnonRouter — and serves it. // // The Group is not a detail of the test: server.New has frozen the AnonRouter by // the time main reaches it, so production mounts inside one, and a test that // mounted on a bare router would be testing a different middleware assembly than // the one that ships. // // There is no Postgres and no store on disk. The nil pool reaches the request // context the way the real one does, and nothing these tests ask for reads it: // tools/list is answered by the protocol server, and a refused mutation never // reaches its handler. func boot(t *testing.T, conf ini.File) *httptest.Server { t.Helper() cfg, err := resolveSettings(conf) require.NoError(t, err) agents, err := newMCPServer(conf, cfg) require.NoError(t, err) root := chi.NewRouter() root.Group(func(r chi.Router) { require.NoError(t, mountRoutes(r, surfaces{ conf: conf, cfg: cfg, stores: noStores{}, mcp: agents, })) }) srv := httptest.NewServer(root) t.Cleanup(srv.Close) return srv } // postMCP sends one JSON-RPC message the way an MCP client does: the two Accept // types the streamable transport requires, and the credential — if any — in the // only header this surface reads. func postMCP(t *testing.T, srv *httptest.Server, token, origin, message string) (*http.Response, string) { t.Helper() req, err := http.NewRequest(http.MethodPost, srv.URL+mcpRoute, strings.NewReader(message)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("MCP-Protocol-Version", "2025-06-18") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } if origin != "" { req.Header.Set("Origin", origin) } resp, err := srv.Client().Do(req) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) body, err := io.ReadAll(resp.Body) require.NoError(t, err) return resp, string(body) } // forgeWorkingToken builds a credential shaped exactly as tokens.sr.ht seals // one: the same format and the same HMAC key as a meta PAT, differing only in // the ClientID — which is the whole routing decision in authn.ResolveBearer. func forgeWorkingToken(username string) string { bt := auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(time.Now().Add(time.Hour)), ClientID: bearer.TokensClientID, Username: username, } return bt.Encode() } // The mount itself: /mcp is reachable on the web listener, with no port and no // process of its own, and it answers the protocol. A tools/list arriving without // a credential is a normal call — anonymity is a caller here — and the tools it // names are the ones the daemon registered. func TestTheMCPSurfaceAnswersOnTheWebListener(t *testing.T) { srv := boot(t, bootConf(t)) resp, body := postMCP(t, srv, "", "", toolsList) require.Equal(t, http.StatusOK, resp.StatusCode, body) assert.Contains(t, body, "list_databases", body) } // The reason /mcp is registered before web.Register and outside its group. An // MCP client is not a browser: it sends no Origin and no Referer, which is // precisely the request the same-origin guard refuses — so a /mcp inside that // group would answer 403 to every agent that ever called it, in production // only, since nothing else on this surface is a mutation a test would notice. // // The second call is the same request as a browser would make it, cross-site // Origin included: still answered, because this surface's protection is its // credential and its Host allowlist, not the group it is not in. func TestTheCSRFGuardDoesNotReachTheMCPSurface(t *testing.T) { srv := boot(t, bootConf(t)) resp, body := postMCP(t, srv, "", "", toolsList) require.Equal(t, http.StatusOK, resp.StatusCode, "an agent sends no Origin at all") resp, body = postMCP(t, srv, "", "https://evil.example", toolsList) assert.Equal(t, http.StatusOK, resp.StatusCode, body) } // The other half of that trade, and the one that would be expensive to get // wrong: putting a route above the same-origin group must not take the group // down with it. A browser-shaped POST to a web form whose Origin is somebody // else's is still refused, by the guard and not by the handler. func TestAWebFormStillRequiresSameOrigin(t *testing.T) { srv := boot(t, bootConf(t)) form := url.Values{"name": {"db"}, "visibility": {"PUBLIC"}} req, err := http.NewRequest(http.MethodPost, srv.URL+"/create", strings.NewReader(form.Encode())) require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", "https://evil.example") resp, err := srv.Client().Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, http.StatusForbidden, resp.StatusCode, string(body)) assert.Contains(t, string(body), csrf.Message, "the refusal is the group's guard, not a handler that happened to fail") } // An instance with no [tokens.sr.ht] section is one that runs no such daemon, // and it is a startable one (docs/DESIGN.mcp.md §10). The surface is still // mounted and still answers anonymous callers; what it cannot do is verify a // working token, so it refuses one — 401 with the challenge, never a downgrade // to anonymous and never a process that will not boot. func TestTheDaemonBootsWithoutATokensSection(t *testing.T) { srv := boot(t, bootConf(t, ecoretest.Delete(tokensSection))) resp, body := postMCP(t, srv, "", "", toolsList) require.Equal(t, http.StatusOK, resp.StatusCode, body) assert.Contains(t, body, "list_databases") resp, body = postMCP(t, srv, forgeWorkingToken("alice"), "", toolsList) require.Equal(t, http.StatusUnauthorized, resp.StatusCode, body) assert.Contains(t, resp.Header.Get("WWW-Authenticate"), "Bearer", "a refused credential is told how to present a better one") }