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"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/graph"
)
// 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, it must publish exactly the scope the endpoint
// checks, and that list must be a JSON array and never a 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. Two different failures live in that one field:
//
// - A null 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.
// - An empty list means no personal access token can be scoped for this service
// at all, so /query could never be federated whatever its own code said: the
// credential api.sr.ht forwards to every service a query touches would be one
// no checkbox could mint. That was spec.sr.ht's state until the endpoint grew
// the meta plane, and it is the half of the refusal no amount of code in
// graph/ could have worked around.
//
// The published scope is compared against graph.GrantScopes rather than a
// literal, because the two spellings that must agree are what meta turns into a
// checkbox and what the endpoint checks. A scope published and not checked admits
// what should have been refused; one checked and not published cannot be minted
// at all; neither is visible from inside a single file. The wire spelling is
// asserted as well, since meta reads the bytes and not the Go value.
func TestAPIMetaPublishesTheScopeTheEndpointChecks(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":["SPECS"]`,
"the wire spelling meta.sr.ht reads, and prefixes into spec.sr.ht/SPECS")
var got apimeta.Meta
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.NotNil(t, got.Scopes,
"a JSON null here is a 500 on meta's personal-token page for the whole instance")
assert.Equal(t, graph.GrantScopes, got.Scopes,
"the file must publish exactly what the endpoint checks")
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))
require.NotNil(t, got.Scopes)
assert.Equal(t, graph.GrantScopes, got.Scopes,
"what meta.sr.ht mints a token for is what /query checks")
})
}
// The meta.sr.ht plane the daemon hands to /query and to nothing else.
//
// What is worth asserting here is not that it builds — graph.New already refuses
// a nil one — but what it is built *with*: the instance owner, so a personal
// access token belonging to anybody else is refused exactly as a foreign working
// token is, and the scope api-meta.json publishes, so the checkbox meta renders
// is the permission this endpoint checks.
func TestNewMetaPlane(t *testing.T) {
plane, err := newMetaPlane("bigbes")
require.NoError(t, err)
require.NotNil(t, plane)
assert.Equal(t, "bigbes", plane.Owner(),
"a PAT of any other account must be refused, and a PAT is what every "+
"account on the instance can mint for itself")
assert.Equal(t, authn.ScopeRead, plane.Scope())
assert.Equal(t, authn.ScopeRead,
authn.ConfigSection+"/"+apiScopes[0], "the published half and the checked half")
// A bad owner is a startup failure and not a plane that admits nobody: the
// daemon must say so while somebody is watching it start.
_, err = newMetaPlane("")
require.Error(t, err)
}
// 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")
})
}