package main
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"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/config"
"sourcecraft.dev/bigbes/sr-ht-core/crypto"
"sourcecraft.dev/bigbes/sr-ht-core/database"
"sourcecraft.dev/bigbes/sr-ht-ecore/apimeta"
)
// TestMain initialises the crypto globals apimeta.Handler reads the webhook
// public key out of. core-go's server.New does it in the daemon; here the
// config from main_test.go stands in.
func TestMain(m *testing.M) {
crypto.InitCrypto(completeConfig())
os.Exit(m.Run())
}
// The route the schema answers on is core-go's own, so that a client which found
// this service through meta.sr.ht — hut, api.sr.ht, a script written against
// git.sr.ht's API — finds /query where it already looks. A service that mounts
// its own endpoint gets no help from core-go here, which is exactly why the
// constant is asserted rather than assumed.
func TestQueryRouteIsCoreGosPath(t *testing.T) {
assert.Equal(t, "/query", queryRoute)
assert.Equal(t, queryRoute+"/api-meta.json", apimeta.Path)
}
// api-meta.json must be served, and its scope list must be an empty array and
// never a JSON null.
//
// meta.sr.ht fetches this file from every service it discovers when it renders
// /oauth2/personal-token, and iterates the "scopes" field to build the grant
// checkboxes. A null there is a nil iteration in meta — a 500 on that page for
// the WHOLE instance, every service's grants and not just this one's. It is a
// failure nobody would find by testing the service that caused it, which is why
// the assertion lives here even though the marshalling is sr-ht-ecore's.
//
// Empty is also the honest answer for spec.sr.ht rather than a placeholder: this
// service defines no meta OAuth scope and no @access directive to check one
// against. Its grant vocabulary is tokens.sr.ht's — authn.ActionRead and
// authn.ActionPropose — which meta neither mints nor advertises.
func TestAPIMetaAdvertisesNoScopeAndNeverNull(t *testing.T) {
rec := httptest.NewRecorder()
// apiScopes and not a literal: this asserts what mountWeb actually serves.
apimeta.Handler(apiScopes...).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, apimeta.Path, nil))
require.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), `"scopes":[]`,
"a JSON null here is a 500 on meta's personal-token page for the whole instance")
var got apimeta.Meta
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.NotNil(t, got.Scopes)
assert.Empty(t, got.Scopes)
assert.NotEmpty(t, got.WebhookPubkey, "a webhook consumer verifies payloads with this")
}
// The wiring itself, over a real chi router: the route the endpoint answers on,
// the two core-go context values the webhook resolvers reach for, and the
// api-meta.json beside it.
//
// The database context is the one worth a test rather than a comment. /query
// moved off the authenticated router, and WithDefaultMiddleware installs
// database.Middleware there and nowhere else — so without this Group every
// webhook mutation would panic on the first transaction, and no read would,
// which is exactly the shape of a bug that reaches production.
func TestMountGraphQL(t *testing.T) {
conf := completeConfig()
pool := &sql.DB{} // never queried: the handler only proves the context carries it
var (
reached bool
gotConf ini.File
gotPool *sql.DB
endpoint = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
gotConf = config.ForContext(r.Context())
// DBForContext and not ForContext: the latter dials a connection,
// and the pool here is a zero value that would panic on one. Both
// read the same context value, which is what is under test.
gotPool = database.DBForContext(r.Context())
w.WriteHeader(http.StatusTeapot)
})
)
router := chi.NewRouter()
mountGraphQL(router, conf, pool, endpoint)
// The web UI claims "/", and "query" is a legal space name. chi resolves by
// trie specificity rather than by registration order — measured, not assumed
// — so /query wins over the catch-all; this is here to prove that rather
// than to rely on the order the daemon happens to register them in.
router.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusGone)
}))
srv := httptest.NewServer(router)
defer srv.Close()
t.Run("the endpoint answers at /query with the core-go context", func(t *testing.T) {
resp, err := srv.Client().Post(srv.URL+queryRoute, "application/json", strings.NewReader(`{}`))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusTeapot, resp.StatusCode, "the web UI's catch-all swallowed /query")
assert.True(t, reached)
assert.Equal(t, conf, gotConf, "config.ForContext panics without config.Middleware")
assert.Same(t, pool, gotPool, "the webhook resolvers open transactions through this")
})
t.Run("api-meta.json is served beside it", func(t *testing.T) {
resp, err := srv.Client().Get(srv.URL + apimeta.Path)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var got apimeta.Meta
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.NotNil(t, got.Scopes)
assert.Empty(t, got.Scopes)
})
}
// The complexity bound core-go's WithSchema would have set. The daemon does not
// call WithSchema any more, and the value's second reader is not the HTTP
// surface at all: the webhook delivery worker runs a subscriber's stored query
// through corewebhooks.Exec, which refuses anything above Server.MaxComplexity.
// Zero there would fail every delivery rather than impose no limit, so what this
// function returns when the instance says nothing is load-bearing.
func TestMaxComplexity(t *testing.T) {
t.Run("defaults to core-go's bound", func(t *testing.T) {
limit, err := maxComplexity(completeConfig())
require.NoError(t, err)
assert.Equal(t, 250, limit)
assert.Equal(t, defaultMaxComplexity, limit)
})
t.Run("an empty value is no value", func(t *testing.T) {
conf := completeConfig()
conf[serviceName+"::api"] = ini.Section{"max-complexity": ""}
limit, err := maxComplexity(conf)
require.NoError(t, err)
assert.Equal(t, defaultMaxComplexity, limit)
})
t.Run("the instance's value wins", func(t *testing.T) {
conf := completeConfig()
conf[serviceName+"::api"] = ini.Section{"max-complexity": "400"}
limit, err := maxComplexity(conf)
require.NoError(t, err)
assert.Equal(t, 400, limit)
})
t.Run("a value that does not parse is an error, not the default", func(t *testing.T) {
conf := completeConfig()
conf[serviceName+"::api"] = ini.Section{"max-complexity": "lots"}
_, err := maxComplexity(conf)
require.Error(t, err, "a limit somebody wrote down and got wrong must not be silently replaced")
})
}