~bigbes/sr-ht-ecore

ref: 3aa1fcfd9a55d3a42f0e8e3f5ca52725f9a2b2a6 sr-ht-ecore/bearer/bearer_test.go -rw-r--r-- 23.6 KiB
3aa1fcfd — Eugene Blikh mcphttp: the MCP endpoint plumbing both services already share 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
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
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
package bearer

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"testing/fstest"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

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

// The identity this validator claims when it calls the daemon — the calling
// service, not tokens.sr.ht.
const (
	callerClientID = "bench.sr.ht"
	callerNodeID   = "bench-1"
)

// TestMain initialises the process-global core-go crypto state this package
// depends on and never sets up itself.
//
// Both keys are load-bearing here and for different steps. [webhooks]private-key
// is what auth.BearerToken.Encode signs with and what step 1 verifies against —
// it is derived into the HMAC key, not used directly. [sr.ht]network-key is the
// fernet key that seals the Internal authorization of step 4, so without it the
// revocation tests would not get past the request. The values are the ones
// core-go's own tests use.
func TestMain(m *testing.M) {
	config.FS = fstest.MapFS{
		"config.ini": &fstest.MapFile{Data: []byte(`
[webhooks]
private-key=ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc=

[sr.ht]
network-key=tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk=
`)},
	}
	crypto.InitCrypto(config.LoadConfig())
	m.Run()
}

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

// seal mints a token exactly the way tokens.sr.ht's service.seal does, so that
// what these tests present is byte-for-byte the shape a real one has.
func seal(username, clientID, grantString string, expires time.Time) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(expires),
		Grants:   grantString,
		ClientID: clientID,
		Username: username,
	}
	return bt.Encode()
}

// ourToken is a live working token from this instance's daemon.
func ourToken(grantString string) string {
	return seal("bigbes", TokensClientID, grantString, time.Now().Add(time.Hour))
}

// daemon is a stand-in for tokens.sr.ht's revocation endpoint. It records every
// request so a test can assert not only what came back but whether anything was
// asked at all — which for step 4 is half of what there is to check.
//
// statuses are consumed one per request, and the last one repeats forever, so
// "fail once then recover" is written as newDaemon(t, 500, 204).
type daemon struct {
	server *httptest.Server

	mu       sync.Mutex
	statuses []int
	hits     int
	paths    []string
	auths    []string
}

func newDaemon(t *testing.T, statuses ...int) *daemon {
	t.Helper()
	require.NotEmpty(t, statuses, "a daemon must be told what to answer")

	d := &daemon{statuses: statuses}
	d.server = httptest.NewServer(http.HandlerFunc(d.serve))
	t.Cleanup(d.server.Close)
	return d
}

func (d *daemon) serve(w http.ResponseWriter, r *http.Request) {
	d.mu.Lock()
	defer d.mu.Unlock()

	d.paths = append(d.paths, r.URL.Path)
	d.auths = append(d.auths, r.Header.Get("Authorization"))

	status := d.statuses[len(d.statuses)-1]
	if d.hits < len(d.statuses) {
		status = d.statuses[d.hits]
	}
	d.hits++
	w.WriteHeader(status)
}

func (d *daemon) count() int {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.hits
}

func (d *daemon) lastPath(t *testing.T) string {
	t.Helper()
	d.mu.Lock()
	defer d.mu.Unlock()
	require.NotEmpty(t, d.paths, "the daemon was never asked anything")
	return d.paths[len(d.paths)-1]
}

func (d *daemon) lastAuth(t *testing.T) string {
	t.Helper()
	d.mu.Lock()
	defer d.mu.Unlock()
	require.NotEmpty(t, d.auths, "the daemon was never asked anything")
	return d.auths[len(d.auths)-1]
}

// fakeClock drives the cache's ageing. It does not drive step 1's expiry check:
// auth.DecodeBearerToken reads the real clock and nothing here can reach it.
type fakeClock struct {
	mu sync.Mutex
	t  time.Time
}

func newClock() *fakeClock {
	return &fakeClock{t: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)}
}

func (c *fakeClock) now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.t
}

func (c *fakeClock) advance(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.t = c.t.Add(d)
}

func newValidator(t *testing.T, origin string, now func() time.Time) *Validator {
	t.Helper()
	v, err := New(Options{
		Origin:   origin,
		ClientID: callerClientID,
		NodeID:   callerNodeID,
		Now:      now,
	})
	require.NoError(t, err)
	return v
}

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

func TestNewRefusesOptionsThatWouldOnlyFailLater(t *testing.T) {
	good := Options{Origin: "https://tokens.srht.bigb.es", ClientID: callerClientID, NodeID: callerNodeID}

	for name, mutate := range map[string]func(*Options){
		"no origin":       func(o *Options) { o.Origin = "" },
		"no scheme":       func(o *Options) { o.Origin = "tokens.srht.bigb.es" },
		"wrong scheme":    func(o *Options) { o.Origin = "ftp://tokens.srht.bigb.es" },
		"no host":         func(o *Options) { o.Origin = "https://" },
		"no client id":    func(o *Options) { o.ClientID = "" },
		"no node id":      func(o *Options) { o.NodeID = "" },
		"negative ttl":    func(o *Options) { o.CacheTTL = -time.Second },
		"unparseable url": func(o *Options) { o.Origin = "https://%zz" },
	} {
		t.Run(name, func(t *testing.T) {
			opts := good
			mutate(&opts)
			v, err := New(opts)
			require.Error(t, err, "a config mistake must not become a request-time 503")
			assert.Nil(t, v)
		})
	}
}

func TestNewFillsTheDefaults(t *testing.T) {
	v, err := New(Options{Origin: "https://tokens.srht.bigb.es/", ClientID: callerClientID, NodeID: callerNodeID})
	require.NoError(t, err)

	assert.Equal(t, DefaultCacheTTL, v.ttl, "a zero TTL means the 60s of SPEC ch. 6")
	assert.NotNil(t, v.client)
	assert.Equal(t, defaultHTTPTimeout, v.client.Timeout, "the default client must not be able to hang")
	assert.NotNil(t, v.now)
	assert.Equal(t, "https://tokens.srht.bigb.es", v.origin,
		"a trailing slash must not become a double slash in the path")
}

// ---------------------------------------------------------------------------
// Step 1: decode and verify
// ---------------------------------------------------------------------------

func TestTamperedTokenIsInvalid(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	good := ourToken("bench:upload id:42")

	// Flip one character of the base64. The payload still decodes; the HMAC does
	// not verify, which is the whole of what makes the format worth anything.
	tampered := []byte(good)
	if tampered[3] == 'A' {
		tampered[3] = 'B'
	} else {
		tampered[3] = 'A'
	}

	for name, presented := range map[string]string{
		"tampered":    string(tampered),
		"empty":       "",
		"not base64":  "!!!not-a-token!!!",
		"too short":   "aGVsbG8",
		"grants only": "bench:upload",
	} {
		t.Run(name, func(t *testing.T) {
			tok, err := v.Validate(context.Background(), presented, "bench:upload")
			require.Error(t, err)
			assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err)
			assert.Nil(t, tok, "a token that failed validation must not be handed back")
		})
	}
	assert.Zero(t, d.count(), "nothing that fails to verify may become a request to the daemon")
}

// The expiry is checked by auth.DecodeBearerToken against the real clock, which
// is why step 1 needs no network and no clock of ours.
func TestExpiredTokenIsInvalidWithoutAskingAnybody(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	expired := seal("bigbes", TokensClientID, "bench:upload id:42", time.Now().Add(-time.Minute))

	tok, err := v.Validate(context.Background(), expired, "bench:upload")
	require.Error(t, err)
	assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err)
	assert.Nil(t, tok)
	assert.Zero(t, d.count(), "an expired token is refused locally, before any network")
}

// ---------------------------------------------------------------------------
// Step 2: is it ours?
// ---------------------------------------------------------------------------

// A meta.sr.ht PAT is signed with the same key and verifies perfectly. Only the
// ClientID tells the two apart, and what to do about it is the service's policy
// — so this returns the token along with the refusal.
func TestForeignClientIDIsNotOursAndComesBackWithTheToken(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	expires := time.Now().Add(time.Hour)
	// Meta's grant vocabulary, not ours: this string does not parse under
	// grants.Parse, so a validator that checked ClientID after parsing would
	// answer ErrInvalid here and a service like dolt would lose the ability to
	// tell a foreign token from a broken one.
	pat := seal("bigbes", "meta.sr.ht", "git.sr.ht/OBJECTS:RW", expires)

	tok, err := v.Validate(context.Background(), pat, "bench:upload")
	require.Error(t, err)
	assert.True(t, errors.Is(err, ErrNotOurs), "want ErrNotOurs, got %v", err)

	require.NotNil(t, tok, "the caller has to be able to look at the token it may still accept")
	assert.Equal(t, "bigbes", tok.Username)
	assert.Equal(t, expires.UTC().Truncate(time.Second), tok.Expires)
	assert.True(t, tok.Grants.Empty(),
		"a foreign grant string is in a foreign vocabulary; this one must admit nothing")
	assert.Zero(t, tok.TokenID)
	assert.Zero(t, d.count(), "a token that is not ours has no revocation of ours to check")
}

// ---------------------------------------------------------------------------
// Step 3: the grant
// ---------------------------------------------------------------------------

func TestMissingGrantIsForbiddenAndStopsBeforeTheNetwork(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	// Registered, so step 4 would have work to do — and must not get the chance.
	tok, err := v.Validate(context.Background(), ourToken("bench:read id:42"), "bench:upload")
	require.Error(t, err)
	assert.True(t, errors.Is(err, ErrForbidden), "want ErrForbidden, got %v", err)
	assert.Nil(t, tok)
	assert.Zero(t, d.count(),
		"step 3 refuses locally; a token that cannot do the thing must not cost a round trip")
}

// Inspect is what a resolver calls in middleware, where the action is not known
// yet — so it must answer for a token whose grants would not cover whatever runs
// next, and leave that refusal to the handler one layer down.
func TestInspectAnswersWithoutAnActionAndLeavesStepThreeToTheCaller(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	tok, err := v.Inspect(context.Background(), ourToken("bench:read id:42"))
	require.NoError(t, err, "a token is inspectable whatever the caller goes on to attempt")
	assert.Equal(t, 42, tok.TokenID)
	assert.True(t, tok.Grants.Has("bench:read"))

	// The revocation half is not the caller's to skip, so it was still asked.
	// This is the one cost of the split: Inspect cannot keep Validate's
	// refuse-before-the-network ordering, because it has no action to refuse on.
	assert.Equal(t, 1, d.count())

	// And step 3 is available where the action finally is known.
	require.NoError(t, tok.Authorize("bench:read"))
	err = tok.Authorize("bench:upload")
	require.Error(t, err)
	assert.True(t, errors.Is(err, ErrForbidden), "want ErrForbidden, got %v", err)
}

// A stateless token has no row, so Inspect completes without touching the
// network at all — the common case under the default configuration.
func TestInspectOfAStatelessTokenTouchesNoNetwork(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	tok, err := v.Inspect(context.Background(), ourToken("bench:upload"))
	require.NoError(t, err)
	assert.Zero(t, tok.TokenID)
	assert.False(t, tok.Registered())
	assert.Zero(t, d.count())
}

// A foreign token is not ours whether it is asked about with an action or
// without one, and both ways hand the decoded token back so the service can
// apply its own meta-PAT policy.
func TestInspectReportsAForeignTokenTheSameWayValidateDoes(t *testing.T) {
	v := newValidator(t, "https://tokens.srht.bigb.es", nil)
	// A meta.sr.ht PAT: same signing key, foreign ClientID, and a grant string in
	// core-go's OAuth grammar that our parser would reject — which is exactly why
	// step 2 has to come before the parse.
	pat := seal("bigbes", "meta.sr.ht", "git.sr.ht/OBJECTS:RW", time.Now().Add(time.Hour))

	tok, err := v.Inspect(context.Background(), pat)
	require.Error(t, err)
	assert.True(t, errors.Is(err, ErrNotOurs), "want ErrNotOurs, got %v", err)
	require.NotNil(t, tok, "step 2 hands the token back; that is the whole point of it")
}

func TestUniversalGrantAdmitsTheAction(t *testing.T) {
	v := newValidator(t, "https://tokens.srht.bigb.es", nil)

	tok, err := v.Validate(context.Background(), ourToken("*"), "bench:upload")
	require.NoError(t, err)
	assert.True(t, tok.Grants.All())
}

// ---------------------------------------------------------------------------
// Step 4: revocation
// ---------------------------------------------------------------------------

// The common case: a short token was never written down, so there is nothing to
// ask and nobody to ask it of. This is the property that keeps tokens.sr.ht off
// the hot path (SPEC ch. 1), so the assertion that matters is the request count.
func TestStatelessTokenNeverTouchesTheNetwork(t *testing.T) {
	d := newDaemon(t, http.StatusNotFound) // would refuse, if it were ever asked
	v := newValidator(t, d.server.URL, nil)

	tok, err := v.Validate(context.Background(), ourToken("bench:upload cover:read"), "bench:upload")
	require.NoError(t, err)

	assert.Equal(t, "bigbes", tok.Username)
	assert.Zero(t, tok.TokenID)
	assert.False(t, tok.Registered())
	assert.True(t, tok.Grants.Has("cover:read"))
	assert.Zero(t, d.count(), "a token with no id: must not reach the daemon at all")
}

func TestRegisteredTokenValidatesAgainstA204(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
	require.NoError(t, err)

	assert.Equal(t, 42, tok.TokenID)
	assert.True(t, tok.Registered())
	assert.Equal(t, 1, d.count())
	assert.Equal(t, "/api/v1/revocations/42", d.lastPath(t))
}

func TestRevokedTokenIsRefused(t *testing.T) {
	d := newDaemon(t, http.StatusNotFound)
	v := newValidator(t, d.server.URL, nil)

	tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
	require.Error(t, err)
	assert.True(t, errors.Is(err, ErrRevoked), "want ErrRevoked, got %v", err)
	assert.Nil(t, tok)
	assert.Equal(t, 1, d.count())
}

// A daemon that cannot answer is not a daemon that answered "revoked". Getting
// this wrong would refuse every registered token on the instance for the length
// of a tokens.sr.ht restart, which is why each case asserts what the error is
// *not* as well as what it is.
func TestADaemonThatCannotAnswerIsUnavailableAndNeverRevoked(t *testing.T) {
	t.Run("500", func(t *testing.T) {
		d := newDaemon(t, http.StatusInternalServerError)
		v := newValidator(t, d.server.URL, nil)

		tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
		require.Error(t, err)
		assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
		assert.False(t, errors.Is(err, ErrRevoked), "unknown is not revoked")
		assert.Nil(t, tok)
	})

	t.Run("401 from the internal guard", func(t *testing.T) {
		// A misconfigured internal-ipnet, or a network key that does not match.
		// It is a deployment fault and the operator has to see it as one, not as
		// every user's token suddenly being revoked.
		d := newDaemon(t, http.StatusUnauthorized)
		v := newValidator(t, d.server.URL, nil)

		_, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
		require.Error(t, err)
		assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
		assert.False(t, errors.Is(err, ErrRevoked))
	})

	t.Run("nothing listening", func(t *testing.T) {
		dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
		origin := dead.URL
		dead.Close() // the port is now refusing connections

		v := newValidator(t, origin, nil)

		tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
		require.Error(t, err)
		assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
		assert.False(t, errors.Is(err, ErrRevoked), "a transport failure is not an answer")
		assert.Nil(t, tok)
	})

	t.Run("context cancelled", func(t *testing.T) {
		d := newDaemon(t, http.StatusNoContent)
		v := newValidator(t, d.server.URL, nil)

		ctx, cancel := context.WithCancel(context.Background())
		cancel()

		_, err := v.Validate(ctx, ourToken("bench:upload id:42"), "bench:upload")
		require.Error(t, err)
		assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
		assert.False(t, errors.Is(err, ErrRevoked))
	})
}

// The internal authorization is the credential that gets past the daemon's
// guard, and it has to name the *calling* service. If it named something else
// the guard would still admit it — it admits every internal service equally —
// and the daemon's log would quietly attribute every check to the wrong caller.
func TestTheInternalBlobNamesTheConfiguredCaller(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	_, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
	require.NoError(t, err)

	scheme, blob, found := strings.Cut(d.lastAuth(t), " ")
	require.True(t, found, "the header must be %q, got %q", "Internal <blob>", d.lastAuth(t))
	assert.Equal(t, "Internal", scheme)

	// Decrypted the way authn.InternalGuard does it, expiry window included: the
	// blob is minted per request precisely so that this window is meaningful.
	payload := crypto.DecryptWithExpiration([]byte(blob), 30*time.Second)
	require.NotNil(t, payload, "the blob must verify under the instance's network key, and be fresh")

	var ia auth.InternalAuth
	require.NoError(t, json.Unmarshal(payload, &ia))
	assert.Equal(t, callerClientID, ia.ClientID)
	assert.Equal(t, callerNodeID, ia.NodeID)
}

// ---------------------------------------------------------------------------
// The cache
// ---------------------------------------------------------------------------

func TestARevocationAnswerIsCachedWithinTheTTL(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	clock := newClock()
	v := newValidator(t, d.server.URL, clock.now)

	token := ourToken("bench:upload id:42")
	for i := 0; i < 5; i++ {
		_, err := v.Validate(context.Background(), token, "bench:upload")
		require.NoError(t, err)
	}
	assert.Equal(t, 1, d.count(), "five validations inside the TTL are one question")

	clock.advance(DefaultCacheTTL - time.Second)
	_, err := v.Validate(context.Background(), token, "bench:upload")
	require.NoError(t, err)
	assert.Equal(t, 1, d.count(), "still inside the TTL")

	clock.advance(2 * time.Second) // now past it
	_, err = v.Validate(context.Background(), token, "bench:upload")
	require.NoError(t, err)
	assert.Equal(t, 2, d.count(), "past the TTL the daemon must be asked again")
}

// The trade SPEC ch. 6 makes on purpose, written down as a test so that nobody
// has to rediscover it during an incident: a revocation takes up to CacheTTL to
// be honoured by a service that already asked.
func TestARevocationTakesUpToTheTTLToTakeEffect(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent, http.StatusNotFound)
	clock := newClock()
	v := newValidator(t, d.server.URL, clock.now)

	token := ourToken("bench:upload id:42")

	_, err := v.Validate(context.Background(), token, "bench:upload")
	require.NoError(t, err)

	// The owner revokes it here. The daemon would now answer 404 — but it is not
	// being asked.
	clock.advance(DefaultCacheTTL / 2)
	_, err = v.Validate(context.Background(), token, "bench:upload")
	assert.NoError(t, err, "inside the TTL the cached 'live' still stands")
	assert.Equal(t, 1, d.count())

	clock.advance(DefaultCacheTTL)
	_, err = v.Validate(context.Background(), token, "bench:upload")
	assert.True(t, errors.Is(err, ErrRevoked), "past the TTL it must be refused; got %v", err)
	assert.Equal(t, 2, d.count())
}

// ErrUnavailable must never be cached. If it were, a single blip would pin every
// token checked during it to failure for a full TTL — turning a moment of
// unavailability into a minute of it, with the daemon healthy the whole time.
func TestUnavailableIsNotCachedWhileALiveAnswerIs(t *testing.T) {
	d := newDaemon(t, http.StatusInternalServerError, http.StatusNoContent)
	clock := newClock()
	v := newValidator(t, d.server.URL, clock.now)

	token := ourToken("bench:upload id:42")

	_, err := v.Validate(context.Background(), token, "bench:upload")
	require.True(t, errors.Is(err, ErrUnavailable), "got %v", err)
	assert.Equal(t, 1, d.count())

	// Immediately afterwards, no clock movement at all: the failure left nothing
	// behind, so the daemon is asked again and the recovery is picked up at once.
	_, err = v.Validate(context.Background(), token, "bench:upload")
	require.NoError(t, err, "a failure to ask must not be remembered as an answer")
	assert.Equal(t, 2, d.count())

	// And the answer that *is* an answer is cached.
	_, err = v.Validate(context.Background(), token, "bench:upload")
	require.NoError(t, err)
	assert.Equal(t, 2, d.count(), "the 204 must be cached")
}

func TestForgetDropsOneCachedAnswer(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent, http.StatusNoContent, http.StatusNotFound)
	clock := newClock()
	v := newValidator(t, d.server.URL, clock.now)

	first := ourToken("bench:upload id:42")
	second := ourToken("bench:upload id:43")

	_, err := v.Validate(context.Background(), first, "bench:upload")
	require.NoError(t, err)
	_, err = v.Validate(context.Background(), second, "bench:upload")
	require.NoError(t, err)
	require.Equal(t, 2, d.count())

	v.Forget(42)

	// 42 is asked again — and the daemon has moved on to answering 404.
	_, err = v.Validate(context.Background(), first, "bench:upload")
	assert.True(t, errors.Is(err, ErrRevoked), "got %v", err)
	assert.Equal(t, 3, d.count())

	// 43 was not forgotten and is still answered from the cache.
	_, err = v.Validate(context.Background(), second, "bench:upload")
	assert.NoError(t, err)
	assert.Equal(t, 3, d.count())

	v.Forget(9999) // forgetting what was never cached is a no-op
}

// The cache must not be a leak in a process that runs for months. The bound is
// a sweep of the expired entries, then — if that was not enough — dropping the
// lot, because every entry costs exactly one round trip to rebuild.
func TestTheCacheIsBounded(t *testing.T) {
	clock := newClock()
	v := newValidator(t, "https://tokens.srht.bigb.es", clock.now)

	for id := 1; id <= maxCacheEntries*2; id++ {
		v.remember(id, true)
	}

	v.mu.Lock()
	size := len(v.cache)
	v.mu.Unlock()
	assert.LessOrEqual(t, size, maxCacheEntries, "the cache must not grow without limit")
	assert.NotZero(t, size, "and it must still be a cache afterwards")
}

// A service holds one Validator and every request handler goes through it, so
// "safe for concurrent use" is a requirement rather than a nicety. Worth running
// under -race.
func TestValidatorIsSafeForConcurrentUse(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	tokens := []string{
		ourToken("bench:upload id:42"),
		ourToken("bench:upload id:43"),
		ourToken("bench:upload"),
	}

	var wg sync.WaitGroup
	for i := 0; i < 32; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			for j := 0; j < 8; j++ {
				_, err := v.Validate(context.Background(), tokens[(i+j)%len(tokens)], "bench:upload")
				assert.NoError(t, err)
			}
			v.Forget(42)
		}(i)
	}
	wg.Wait()
}