package bearer
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// The grant strings these benchmarks present. The stateless one is what a short
// token carries; the registered one adds the id: member that turns step 4 from
// a no-op into a cache lookup.
const (
benchGrants = "bench:upload cov:upload artifacts:upload"
benchGrantsRegistered = benchGrants + " id:4711"
)
// The sinks keep a validated token from being discarded as an unread result.
var (
sinkToken *Token
sinkErr error
)
// liveDaemon stands in for tokens.sr.ht's revocation endpoint and answers 204
// — live — to everything.
//
// It is only ever asked once per benchmark: the warm-up call below populates
// the cache, and every measured iteration is then the cached path, which is the
// one a running service spends its time in. A benchmark that hit the server on
// every iteration would be measuring httptest's loopback, not this package.
func liveDaemon(b *testing.B) *httptest.Server {
b.Helper()
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
b.Cleanup(s.Close)
return s
}
// benchValidator builds a validator pointed at a daemon that says "live".
func benchValidator(b *testing.B) *Validator {
b.Helper()
s := liveDaemon(b)
v, err := New(Options{
Origin: s.URL,
ClientID: callerClientID,
NodeID: callerNodeID,
HTTPClient: s.Client(),
})
if err != nil {
b.Fatalf("building the validator: %v", err)
}
return v
}
// BenchmarkValidate is the whole of SPEC ch. 6 on one presented token, which is
// what a service pays per authenticated request.
//
// The four cases are the four outcomes that happen in production:
//
// - stateless: an HMAC, a grant parse and a map lookup, and no network at all.
// This is the common case under the default configuration and the number
// that matters.
// - registered_cached: the same, plus step 4 answered from the cache under the
// validator's mutex. The difference between it and stateless is the price of
// revocability.
// - forbidden: a good credential that does not carry the action. It costs more
// than a success, because the refusal renders the grant set into its message
// — worth knowing, since a misconfigured client retries this in a loop.
// - invalid: junk. Step 1 refuses it without allocating a token, and this is
// the path an instance under a flood of forged credentials runs; it must
// stay the cheapest thing here.
func BenchmarkValidate(b *testing.B) {
ctx := context.Background()
v := benchValidator(b)
stateless := ourToken(benchGrants)
registered := ourToken(benchGrantsRegistered)
// Warm the revocation cache, and check that the fixture validates at all —
// a token that fails here would turn every measured iteration into an early
// refusal and report it as a fast success.
if _, err := v.Validate(ctx, registered, "bench:upload"); err != nil {
b.Fatalf("the registered fixture must validate: %v", err)
}
b.Run("stateless", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
sinkToken, sinkErr = v.Validate(ctx, stateless, "bench:upload")
}
if sinkErr != nil {
b.Fatalf("unexpected refusal: %v", sinkErr)
}
})
b.Run("registered_cached", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
sinkToken, sinkErr = v.Validate(ctx, registered, "bench:upload")
}
if sinkErr != nil {
b.Fatalf("unexpected refusal: %v", sinkErr)
}
})
b.Run("forbidden", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
sinkToken, sinkErr = v.Validate(ctx, stateless, "dolt:push")
}
if !errors.Is(sinkErr, ErrForbidden) {
b.Fatalf("this case must be refused with ErrForbidden, got %v", sinkErr)
}
})
b.Run("invalid", func(b *testing.B) {
// Not a token: the shape a scanner presents.
const junk = "not-a-token-at-all-just-a-string-somebody-tried"
b.ReportAllocs()
for b.Loop() {
sinkToken, sinkErr = v.Validate(ctx, junk, "bench:upload")
}
if !errors.Is(sinkErr, ErrInvalid) {
b.Fatalf("this case must be refused with ErrInvalid, got %v", sinkErr)
}
})
}
// BenchmarkInspect is the path a service's identity middleware runs: the same
// four steps without step 3, once per request, upstream of the router. Read it
// beside BenchmarkValidate/stateless — the gap is the grant check that Inspect
// leaves to the handler.
func BenchmarkInspect(b *testing.B) {
ctx := context.Background()
v := benchValidator(b)
presented := ourToken(benchGrants)
b.ReportAllocs()
for b.Loop() {
sinkToken, sinkErr = v.Inspect(ctx, presented)
}
if sinkErr != nil {
b.Fatalf("unexpected refusal: %v", sinkErr)
}
}
// BenchmarkValidateParallel is the same registered token validated from many
// goroutines at once, which is how a service actually holds a Validator: one of
// them, behind every request handler.
//
// The revocation cache is a map behind a single mutex, so this is where that
// choice shows up. A per-request HMAC is embarrassingly parallel; a shared lock
// is not, and the point of measuring it is to know which of the two dominates
// before somebody proposes sharding the cache.
func BenchmarkValidateParallel(b *testing.B) {
ctx := context.Background()
v := benchValidator(b)
registered := ourToken(benchGrantsRegistered)
if _, err := v.Validate(ctx, registered, "bench:upload"); err != nil {
b.Fatalf("the registered fixture must validate: %v", err)
}
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, err := v.Validate(ctx, registered, "bench:upload"); err != nil {
b.Errorf("unexpected refusal: %v", err)
return
}
}
})
}