~bigbes/sr-ht-spec

sr-ht-spec/authn/meta_test.go -rw-r--r-- 12.9 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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
package authn

import (
	"context"
	"errors"
	"net/http"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vaughan0/go-ini"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/config"

	"sourcecraft.dev/bigbes/sr-ht-ecore/metapat"
)

// The meta.sr.ht plane as a unit: what it makes of an answer from the shared
// validator, and what each of that validator's refusals means here.
//
// graph/credential_test.go drives the same plane through a real handler with a
// real metapat.Validator behind it. These go in at the seam instead, because the
// arms that matter most — a meta.sr.ht that will not answer, a sentinel nobody
// has written yet — cannot be provoked from the outside without breaking
// something on purpose.

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

// stubPATs is an in-memory MetaValidator: it answers with whatever the test put
// in it, which is how every arm of the classification below is exercised with no
// meta.sr.ht, no network and no database.
type stubPATs struct {
	ac    *auth.AuthContext
	err   error
	calls int
}

func (s *stubPATs) Resolve(context.Context, string) (*auth.AuthContext, error) {
	s.calls++
	if s.err != nil {
		return nil, s.err
	}
	return s.ac, nil
}

// resolvedPAT is what metapat.Resolve leaves behind for a live personal access
// token of username carrying grantString: core-go's own OAuth2 caller, with the
// grants decoded in meta's vocabulary.
//
// They are decoded through a context naming this service because
// auth.DecodeGrants reads that name to expand a grant written without one — and
// panics when nothing put it there. That is the trap metapat.Options.Service
// exists to close, met here from the other side.
func resolvedPAT(t *testing.T, username, grantString string) *auth.AuthContext {
	t.Helper()
	g, err := auth.DecodeGrants(
		config.Context(context.Background(), ini.File{}, ConfigSection), grantString)
	require.NoError(t, err)

	return &auth.AuthContext{
		AuthMethod:  auth.AUTH_OAUTH2,
		UserID:      1,
		Username:    username,
		Email:       username + "@example.org",
		BearerToken: &auth.BearerToken{Username: username, Grants: grantString},
		Grants:      g,
	}
}

// newMetaAuth builds the plane over a validator that answers every credential
// the same way, for the instance owner these tests use throughout.
func newMetaAuth(t *testing.T, ac *auth.AuthContext, resolveErr error) (*MetaAuth, *stubPATs) {
	t.Helper()
	stub := &stubPATs{ac: ac, err: resolveErr}
	plane, err := NewMetaAuth(stub, "bigbes", ScopeRead)
	require.NoError(t, err)
	return plane, stub
}

// errUnknownToTheTable is a refusal this package's classification has never seen.
// It stands for the sentinel a future version of metapat adds without telling
// anybody, which is the case the fail-closed default exists for.
var errUnknownToTheTable = errors.New("metapat: something nobody has classified")

// ---------------------------------------------------------------------------
// Construction
// ---------------------------------------------------------------------------

func TestNewMetaAuthRefusesAPlaneThatCannotWork(t *testing.T) {
	t.Run("a nil validator", func(t *testing.T) {
		// Not the "this instance has no such plane" configuration — that one is a
		// nil *MetaAuth, and graph.New refuses even that — but a plane that would
		// fail every PAT while looking configured.
		_, err := NewMetaAuth(nil, "bigbes", ScopeRead)
		require.Error(t, err)
		assert.Contains(t, err.Error(), "MetaValidator")
	})

	t.Run("an empty owner", func(t *testing.T) {
		// Every token's account would be compared against "", so the check that
		// makes this a single-owner instance would be the one silently doing
		// nothing.
		_, err := NewMetaAuth(&stubPATs{}, "", ScopeRead)
		require.Error(t, err)
		assert.Contains(t, err.Error(), "owner")
	})

	t.Run("an owner that is not a usable name", func(t *testing.T) {
		// The same rule NewResolver applies, so the two planes cannot disagree
		// about who the instance owner is.
		_, err := NewMetaAuth(&stubPATs{}, "not a username", ScopeRead)
		require.Error(t, err)
	})

	t.Run("an empty scope", func(t *testing.T) {
		// metapat.Allows would be asked about the grant name "", which no token
		// carries and no meta checkbox can mint: every PAT on the instance
		// refused, with a message naming a permission that does not exist.
		_, err := NewMetaAuth(&stubPATs{}, "bigbes", "")
		require.Error(t, err)
		assert.Contains(t, err.Error(), "scope")
	})
}

func TestTheOwnerAndScopeAreSpelledOnceAndReadBack(t *testing.T) {
	plane, _ := newMetaAuth(t, nil, nil)
	assert.Equal(t, ScopeRead, plane.Scope())
	assert.Equal(t, "bigbes", plane.Owner())

	// The leading '~' is a display convention, not part of the name, and both
	// planes strip it — a resolver built with "~bigbes" and one built with
	// "bigbes" must admit the same tokens.
	tilde, err := NewMetaAuth(&stubPATs{}, "~bigbes", ScopeRead)
	require.NoError(t, err)
	assert.Equal(t, "bigbes", tilde.Owner())

	// The two halves the instance has to agree on: what meta prefixes into a
	// checkbox, and what this service checks.
	assert.Equal(t, ScopeRead, metapat.Scope(ConfigSection, metapat.ScopeName(ScopeRead)))
}

// ---------------------------------------------------------------------------
// VerifyToken
// ---------------------------------------------------------------------------

func TestAPersonalAccessTokenCarryingTheScopeIsAnAgent(t *testing.T) {
	plane, stub := newMetaAuth(t, resolvedPAT(t, "bigbes", ScopeRead+":RO"), nil)

	p, err := plane.VerifyToken(t.Context(), "presented")
	require.NoError(t, err)

	assert.Equal(t, 1, stub.calls)
	assert.Equal(t, KindAgent, p.Kind)
	assert.Equal(t, PlaneMeta, p.Plane)
	assert.Equal(t, "bigbes", p.Owner)
	assert.Equal(t, 1, p.UserID, "the row id every user-scoped lookup keys off")
	assert.True(t, p.CanRead())

	// It may read and it may not approve: KindOwner is the human at a browser,
	// and a PAT is a string any process holding it can present.
	assert.False(t, p.IsOwner())

	// It carries no tokens.sr.ht grants, and must not be asked for any — no PAT
	// can ever be minted with spec:read, so a grant check here would refuse every
	// one of them rather than scope it.
	assert.NoError(t, p.Authorize(ActionRead))
	assert.NoError(t, p.Authorize(ActionPropose))
}

func TestAnUngrantedPersonalAccessTokenIsUniversal(t *testing.T) {
	// meta.sr.ht mints a token with no grants selected and core-go reads that as
	// every permission (auth.Grants.HasAll). Refusing it here would refuse the
	// commonest credential on the instance — and the one a federated query is
	// most likely to arrive holding.
	plane, _ := newMetaAuth(t, resolvedPAT(t, "bigbes", ""), nil)

	p, err := plane.VerifyToken(t.Context(), "presented")
	require.NoError(t, err)
	assert.Equal(t, PlaneMeta, p.Plane)
}

func TestAPersonalAccessTokenWithoutTheScopeIsForbidden(t *testing.T) {
	plane, _ := newMetaAuth(t, resolvedPAT(t, "bigbes", "meta.sr.ht/PROFILE:RO"), nil)

	p, err := plane.VerifyToken(t.Context(), "presented")
	require.ErrorIs(t, err, ErrMissingScope)
	assert.Equal(t, http.StatusForbidden, StatusFor(err),
		"403: the credential is fine, the permission is not")
	assert.False(t, IsAuthFailure(err), "401 would send the holder re-minting a good token")
	assert.True(t, p.IsAnonymous())
	assert.Contains(t, err.Error(), ScopeRead, "the refusal must name what to go and obtain")
}

func TestAPersonalAccessTokenOfAnotherOwnerIsForbidden(t *testing.T) {
	// The single-owner rule, applied to the credential every account on the
	// instance can mint for itself. Without it the widest credential in existence
	// would be the one that skipped the narrowest identity check.
	//
	// 403 and not 401, and the same sentinel a foreign working token gets: the
	// token verifies and the holder is who they say they are, there is simply
	// nothing on this instance to grant them, so retrying will not help.
	plane, _ := newMetaAuth(t, resolvedPAT(t, "somebody-else", ScopeRead), nil)

	p, err := plane.VerifyToken(t.Context(), "presented")
	require.ErrorIs(t, err, ErrNotInstanceOwner)
	assert.Equal(t, http.StatusForbidden, StatusFor(err))
	assert.True(t, p.IsAnonymous())

	// The refusal is about whose token it is and not about what it carries: this
	// one holds the scope and is refused anyway, because a scope says what a
	// token may do and never whose it is.
	assert.NotErrorIs(t, err, ErrMissingScope)
}

func TestTheOwnerIsRecognisedThroughATildeInTheToken(t *testing.T) {
	// meta.sr.ht writes usernames both ways depending on the call, and the other
	// plane already strips one leading '~' before comparing. A plane that did not
	// would refuse the instance owner's own token half the time.
	plane, _ := newMetaAuth(t, resolvedPAT(t, "~bigbes", ScopeRead), nil)

	p, err := plane.VerifyToken(t.Context(), "presented")
	require.NoError(t, err)
	assert.Equal(t, "bigbes", p.Owner)
}

func TestAnAbsentCredentialIsNotARefusal(t *testing.T) {
	// ErrNoToken is what the caller reads as "nothing was presented" rather than
	// as a rejection, and nothing is asked of meta.sr.ht for it.
	plane, stub := newMetaAuth(t, nil, nil)

	_, err := plane.VerifyToken(t.Context(), "")
	require.ErrorIs(t, err, ErrNoToken)
	assert.Zero(t, stub.calls, "nothing to validate, so nothing is asked")
}

func TestAResolvedCallerWithNoIDIsRefused(t *testing.T) {
	// metapat refuses this itself; the check is kept because the seam is an
	// interface, and a zero id downstream would own whichever row has an unset
	// owner.
	ac := resolvedPAT(t, "bigbes", ScopeRead)
	ac.UserID = 0
	plane, _ := newMetaAuth(t, ac, nil)

	_, err := plane.VerifyToken(t.Context(), "presented")
	require.ErrorIs(t, err, ErrInvalidPersonalToken)
	assert.True(t, IsAuthFailure(err))
	assert.Equal(t, http.StatusUnauthorized, StatusFor(err))
}

// ---------------------------------------------------------------------------
// classifyResolve — the table the surfaces turn into statuses
// ---------------------------------------------------------------------------

func TestEveryRefusalOfTheSharedValidatorIsClassified(t *testing.T) {
	cases := []struct {
		name   string
		from   error
		want   error
		status int
	}{
		{
			name: "a forged or expired token", from: metapat.ErrInvalid,
			want: ErrInvalidPersonalToken, status: http.StatusUnauthorized,
		},
		{
			name: "a revoked token", from: metapat.ErrRevoked,
			want: ErrInvalidPersonalToken, status: http.StatusUnauthorized,
		},
		{
			// Unreachable in production — graph routes on metapat.PlaneOf before
			// this plane is asked — and classified so the table is total. Were it
			// ever to arrive it is a bad credential *for this plane*.
			name: "a working token that reached the wrong plane", from: metapat.ErrNotOurs,
			want: ErrInvalidPersonalToken, status: http.StatusUnauthorized,
		},
		{
			// Resolve is never told a scope, so it cannot raise this; classified
			// so the table is total.
			name: "a scope refusal from the validator itself", from: metapat.ErrForbidden,
			want: ErrMissingScope, status: http.StatusForbidden,
		},
		{
			// The arm that has to be defended: "I could not check" is not "your
			// credential is bad", and 401 here would tell every federated client
			// on the instance to re-mint over a meta.sr.ht restart.
			name: "meta.sr.ht unreachable", from: metapat.ErrUnavailable,
			want: ErrMetaUnavailable, status: http.StatusServiceUnavailable,
		},
		{
			// Fail-closed: a sentinel this table has never seen must read as "I
			// could not decide", never as a verdict about the credential.
			name: "a sentinel nobody has classified yet", from: errUnknownToTheTable,
			want: ErrMetaUnavailable, status: http.StatusServiceUnavailable,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			plane, _ := newMetaAuth(t, nil, tc.from)

			p, err := plane.VerifyToken(t.Context(), "presented")
			require.ErrorIs(t, err, tc.want)
			assert.ErrorIs(t, err, tc.from, "the cause must survive for the log")
			assert.Equal(t, tc.status, StatusFor(err))
			assert.True(t, p.IsAnonymous(), "a refused caller carries no authority")
		})
	}
}

// The two planes owe the surfaces above the same three answers, so their tables
// must not drift apart. This asserts the property rather than the code: every
// refusal this plane can produce lands on exactly one of 401, 403 and 503, and
// never on 200.
func TestTheClassificationIsTotalAndUnambiguous(t *testing.T) {
	for _, from := range []error{
		metapat.ErrInvalid, metapat.ErrNotOurs, metapat.ErrRevoked,
		metapat.ErrForbidden, metapat.ErrUnavailable, errUnknownToTheTable,
	} {
		plane, _ := newMetaAuth(t, nil, from)
		_, err := plane.VerifyToken(t.Context(), "presented")
		require.Error(t, err)

		status := StatusFor(err)
		assert.Contains(t,
			[]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusServiceUnavailable},
			status, "%v mapped to %d", from, status)
	}
}