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