package main
import (
"context"
"encoding/json"
"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/apimeta"
"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) InitEmptyStore(context.Context, 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) MoveStore(context.Context, string, 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)
gql, err := newGraphServer(conf)
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,
gql: gql,
}))
})
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")
}
// --- the GraphQL surface ------------------------------------------------------
// postQuery sends a GraphQL query to the booted daemon, as a client does:
// POST, JSON, and an Origin only when one is being tested.
func postQuery(t *testing.T, srv *httptest.Server, origin, query string) (*http.Response, string) {
t.Helper()
body, err := json.Marshal(map[string]any{"query": query})
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, srv.URL+queryRoute, strings.NewReader(string(body)))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
if origin != "" {
req.Header.Set("Origin", origin)
}
resp, err := srv.Client().Do(req)
require.NoError(t, err)
t.Cleanup(func() { _ = resp.Body.Close() })
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp, string(raw)
}
// The schema answers on the web listener, mounted where /mcp is. Introspection
// is the one query that needs neither Postgres nor a store, so it is what the
// boot test asks: a wiring mistake shows up here rather than in production.
func TestTheGraphQLSurfaceAnswersOnTheWebListener(t *testing.T) {
srv := boot(t, bootConf(t))
resp, body := postQuery(t, srv, "", `{ __schema { queryType { name } } }`)
require.Equal(t, http.StatusOK, resp.StatusCode, body)
assert.Contains(t, body, `"name":"Query"`, body)
}
// /query is above the same-origin group for /mcp's reason: an API client sends
// no Origin, which is exactly the request that guard refuses. A cross-site
// Origin is answered too — this surface's protection is its credential and the
// access matrix, not the group it is not in.
func TestTheCSRFGuardDoesNotReachTheGraphQLSurface(t *testing.T) {
srv := boot(t, bootConf(t))
resp, body := postQuery(t, srv, "", `{ __typename }`)
require.Equal(t, http.StatusOK, resp.StatusCode, "an API client sends no Origin at all")
resp, body = postQuery(t, srv, "https://evil.example", `{ __typename }`)
assert.Equal(t, http.StatusOK, resp.StatusCode, body)
}
// The file meta.sr.ht reads, served on the daemon rather than merely built:
// core-go serves it only for the schemas it hosts itself, so a service that
// mounts its own /query has to remember this route.
func TestTheAPIMetaFileIsServed(t *testing.T) {
srv := boot(t, bootConf(t))
resp, err := srv.Client().Get(srv.URL + apimeta.Path)
require.NoError(t, err)
t.Cleanup(func() { _ = resp.Body.Close() })
require.Equal(t, http.StatusOK, resp.StatusCode)
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var got apimeta.Meta
require.NoError(t, json.Unmarshal(raw, &got))
assert.Equal(t, []string{repoScopeName}, got.Scopes)
assert.NotEmpty(t, got.WebhookPubkey)
}