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