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") }) }