~bigbes/sr-ht-spec

ref: 5bb0bb134d608263da3197df9fb4f1d8a3fe42db sr-ht-spec/cmd/specsrht/graphql_test.go -rw-r--r-- 6.9 KiB
5bb0bb13 — Eugene Blikh graph: serve /query on the anonymous router with a bearer credential 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
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")
	})
}