~bigbes/sr-ht-spec

sr-ht-spec/cmd/specsrht/graphql_test.go -rw-r--r-- 9.0 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day 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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
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")
	})
}