package metapat
import (
"context"
"errors"
"fmt"
"io"
"log"
"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"
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
)
// TestMain initialises the process-global core-go crypto state this package
// depends on and never sets up itself.
//
// [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.
// The value is the one core-go's own tests use.
//
// The std logger is silenced because auth.DecodeBearerToken narrates every
// refusal through it, and the refusals are half of what this file tests: without
// this, one `go test` prints a page of "Invalid bearer token" for tokens that
// were invalid on purpose.
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())
log.SetOutput(io.Discard)
m.Run()
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const (
// metaClientID is what meta.sr.ht stamps into a personal access token: the
// UUID of the OAuth client it was minted for. Its only load-bearing property
// here is that it is not bearer.TokensClientID.
metaClientID = "b2a5e8b0-0c8f-4e4a-9a34-1f9f6b3a0c11"
covService = "cov.sr.ht"
covReports = covService + "/REPORTS"
)
// seal mints a token the way the instance's daemons do, 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()
}
// pat is a live personal access token from meta.sr.ht.
func pat(grantString string) string {
return seal("bigbes", metaClientID, grantString, time.Now().Add(time.Hour))
}
// workingToken is a live token from tokens.sr.ht — the other plane.
func workingToken() string {
return seal("bigbes", bearer.TokensClientID, "cov:read", time.Now().Add(time.Hour))
}
// backend is a stand-in for meta.sr.ht. It records every call so a test can
// assert not only what came back but whether anything was asked at all — which,
// for the cache, is the whole question.
type backend struct {
mu sync.Mutex
// userID is filled into every looked-up profile. Zero is the "meta answered
// without an id" case, which is a refusal and not a lookup failure.
userID int
// lookupErr and revokedErr make the two network steps fail.
lookupErr error
revokedErr error
// revoked is what IsRevoked answers when it does not fail.
revoked bool
lookups int
revChecks int
names []string
clientIDs []string
}
func (b *backend) LookupUser(_ context.Context, username string, out *auth.AuthContext) error {
b.mu.Lock()
defer b.mu.Unlock()
b.lookups++
b.names = append(b.names, username)
if b.lookupErr != nil {
return b.lookupErr
}
out.UserID = b.userID
out.Username = username
out.Email = username + "@example.org"
return nil
}
func (b *backend) IsRevoked(_ context.Context, _ string, _ [64]byte, clientID string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.revChecks++
b.clientIDs = append(b.clientIDs, clientID)
if b.revokedErr != nil {
return false, b.revokedErr
}
return b.revoked, nil
}
func (b *backend) counts() (lookups, revChecks int) {
b.mu.Lock()
defer b.mu.Unlock()
return b.lookups, b.revChecks
}
// newValidator builds a validator over a healthy backend that resolves everyone
// to user 42, which is what most of these tests want.
func newValidator(t *testing.T, opts ...func(*Options)) (*Validator, *backend) {
t.Helper()
b := &backend{userID: 42}
o := Options{Service: covService, Backend: b}
for _, fn := range opts {
fn(&o)
}
v, err := New(o)
require.NoError(t, err)
return v, b
}
// ---------------------------------------------------------------------------
// PlaneOf
// ---------------------------------------------------------------------------
func TestPlaneOfSeparatesTheTwoPlanes(t *testing.T) {
assert.Equal(t, PlaneMeta, PlaneOf(pat("")))
assert.Equal(t, PlaneWorking, PlaneOf(workingToken()))
}
func TestPlaneOfRefusesWhatItCannotRead(t *testing.T) {
// A credential this process cannot decode is PlaneUnknown whoever sealed it:
// the routing question has no answer, and the service owes it a 401 rather
// than a trip to either daemon.
assert.Equal(t, PlaneUnknown, PlaneOf(""))
assert.Equal(t, PlaneUnknown, PlaneOf("not a token at all"))
assert.Equal(t, PlaneUnknown, PlaneOf("!!!not even base64!!!"))
}
func TestPlaneOfReadsAnExpiredTokenAsUnknown(t *testing.T) {
// auth.DecodeBearerToken checks expiry before it reports anything, so an
// expired PAT never reaches PlaneMeta. The service must answer it like any
// other unreadable credential — the point of documenting this on PlaneOf is
// that "unknown" is tempting to read as "no credential presented".
expired := seal("bigbes", metaClientID, "", time.Now().Add(-time.Hour))
assert.Equal(t, PlaneUnknown, PlaneOf(expired))
}
func TestPlaneNamesItself(t *testing.T) {
assert.Equal(t, "meta.sr.ht personal access token", PlaneMeta.String())
assert.Equal(t, "tokens.sr.ht working token", PlaneWorking.String())
assert.Equal(t, "unrecognised credential", PlaneUnknown.String())
assert.Equal(t, "unrecognised credential", Plane(99).String())
}
// ---------------------------------------------------------------------------
// New
// ---------------------------------------------------------------------------
func TestNewFillsInTheProductionDefaults(t *testing.T) {
v, err := New(Options{Service: covService})
require.NoError(t, err)
assert.NotNil(t, v.backend, "a nil Backend must become the core one")
assert.Equal(t, DefaultCacheTTL, v.ttl)
assert.NotNil(t, v.now)
assert.NotNil(t, v.cache)
}
func TestNewRefusesANegativeTTL(t *testing.T) {
// Refused at construction rather than later: a negative TTL expires every
// entry the instant it is written, which is not a cache misbehaving but a
// service quietly asking meta.sr.ht once per request forever.
_, err := New(Options{Service: covService, CacheTTL: -time.Second})
require.Error(t, err)
assert.Contains(t, err.Error(), "negative")
}
func TestNewRefusesAnUnnamedService(t *testing.T) {
// Without it, decoding a grant string reads the service name off the ambient
// context and PANICS when nothing put one there. Refusing here turns a crash
// in whichever caller runs outside an HTTP router into a wiring error at
// startup.
_, err := New(Options{})
require.Error(t, err)
assert.Contains(t, err.Error(), "Service is required")
}
func TestResolveNeedsNoAmbientConfigContext(t *testing.T) {
// The whole reason Options.Service exists. context.Background() carries no
// service name, and core-go's config.ServiceName panics rather than
// answering "" — so a shared package that leaned on the request context
// would take down every caller that is not an HTTP handler.
v, _ := newValidator(t)
require.NotPanics(t, func() {
ac, err := v.Resolve(context.Background(), pat(covReports))
require.NoError(t, err)
assert.True(t, Allows(ac, covReports, auth.RO))
})
}
// ---------------------------------------------------------------------------
// Resolve — the happy path
// ---------------------------------------------------------------------------
func TestResolveProducesAnOAuth2Caller(t *testing.T) {
v, b := newValidator(t)
token := pat(covReports + ":RO")
ac, err := v.Resolve(context.Background(), token)
require.NoError(t, err)
// The shape core-go's own OAuth2 middleware produces, because everything
// downstream — Allows included — is written against that shape.
assert.Equal(t, auth.AUTH_OAUTH2, ac.AuthMethod)
assert.Equal(t, 42, ac.UserID)
assert.Equal(t, "bigbes", ac.Username)
require.NotNil(t, ac.BearerToken)
assert.Equal(t, metaClientID, ac.BearerToken.ClientID)
assert.NotEqual(t, [64]byte{}, ac.TokenHash)
assert.True(t, Allows(ac, covReports, auth.RO))
// The revocation row is scoped by the token's own client id, not by ours.
assert.Equal(t, []string{metaClientID}, b.clientIDs)
assert.Equal(t, []string{"bigbes"}, b.names)
}
func TestResolveAcceptsAnUngrantedTokenAsUniversal(t *testing.T) {
// meta.sr.ht mints a personal token with no grants selected, and core-go
// reads that as every permission. Refusing it here would refuse the most
// common credential on the instance.
v, _ := newValidator(t)
ac, err := v.Resolve(context.Background(), pat(""))
require.NoError(t, err)
assert.True(t, ac.Grants.HasAll())
assert.True(t, Allows(ac, covReports, auth.RW))
}
// ---------------------------------------------------------------------------
// Resolve — the refusals
// ---------------------------------------------------------------------------
func TestResolveRefusesAnEmptyCredential(t *testing.T) {
v, b := newValidator(t)
_, err := v.Resolve(context.Background(), "")
require.ErrorIs(t, err, ErrInvalid)
lookups, revChecks := b.counts()
assert.Zero(t, lookups)
assert.Zero(t, revChecks)
}
func TestResolveRefusesAForgedCredentialWithoutAskingMeta(t *testing.T) {
// The ordering is the property: everything that can refuse locally runs
// before anything that touches the network, so a flood of junk tokens does
// not become a flood of requests against meta.sr.ht.
v, b := newValidator(t)
_, err := v.Resolve(context.Background(), "this is not a bearer token")
require.ErrorIs(t, err, ErrInvalid)
lookups, revChecks := b.counts()
assert.Zero(t, lookups)
assert.Zero(t, revChecks)
}
func TestResolveRefusesAnExpiredToken(t *testing.T) {
v, b := newValidator(t)
expired := seal("bigbes", metaClientID, "", time.Now().Add(-time.Hour))
_, err := v.Resolve(context.Background(), expired)
require.ErrorIs(t, err, ErrInvalid)
lookups, _ := b.counts()
assert.Zero(t, lookups)
}
func TestResolveSendsAWorkingTokenBackToTheOtherPlane(t *testing.T) {
// Not a refusal on the instance's behalf: the token is perfectly good, it
// simply cannot be checked here — its grants are in another vocabulary and
// its revocation row is at another daemon.
v, b := newValidator(t)
ac, err := v.Resolve(context.Background(), workingToken())
require.ErrorIs(t, err, ErrNotOurs)
assert.Nil(t, ac, "a caller must not be handed a context it cannot use")
lookups, _ := b.counts()
assert.Zero(t, lookups, "the other plane's token must not cost a meta lookup")
}
func TestResolveTreatsALookupFailureAsTransient(t *testing.T) {
// The 503 that has to be defended: "I could not check" is not "your
// credential is bad", and answering 401 here tells every client on the
// instance to re-mint credentials that were never broken.
v, b := newValidator(t)
b.lookupErr = errors.New("dial tcp: connection refused")
_, err := v.Resolve(context.Background(), pat(""))
require.ErrorIs(t, err, ErrUnavailable)
assert.NotErrorIs(t, err, ErrInvalid)
}
func TestResolveRefusesAProfileWithNoID(t *testing.T) {
// meta answered, and answered uselessly. Permanent rather than transient:
// retrying will not conjure the account back, and a zero id downstream
// matches whichever row has an unset owner.
v, b := newValidator(t)
b.userID = 0
_, err := v.Resolve(context.Background(), pat(""))
require.ErrorIs(t, err, ErrInvalid)
assert.NotErrorIs(t, err, ErrUnavailable)
_, revChecks := b.counts()
assert.Zero(t, revChecks, "a caller with no id is refused before the revocation check")
}
func TestResolveTreatsARevocationOutageAsTransient(t *testing.T) {
v, b := newValidator(t)
b.revokedErr = errors.New("meta.sr.ht: 502")
_, err := v.Resolve(context.Background(), pat(""))
require.ErrorIs(t, err, ErrUnavailable)
}
func TestResolveRefusesARevokedToken(t *testing.T) {
v, b := newValidator(t)
b.revoked = true
_, err := v.Resolve(context.Background(), pat(""))
require.ErrorIs(t, err, ErrRevoked)
// 401 and not 403: the token is no longer a credential at all, and a client
// shown 403 keeps presenting it.
assert.NotErrorIs(t, err, ErrForbidden)
}
func TestResolveRefusesAMalformedGrantString(t *testing.T) {
// core-go's grant grammar is "<service>/<scope>[:<mode>]". A token whose
// grant string does not parse is not one this instance can reason about.
v, _ := newValidator(t)
_, err := v.Resolve(context.Background(), pat("garbage-without-a-slash"))
require.ErrorIs(t, err, ErrInvalid)
}
func TestResolveDoesNotCacheARefusal(t *testing.T) {
// A refusal costs one local HMAC to reproduce; caching it would hand an
// attacker a data structure to grow by presenting garbage, and would pin a
// token to failure across a meta outage that has since ended.
v, b := newValidator(t)
b.lookupErr = errors.New("down")
token := pat("")
_, err := v.Resolve(context.Background(), token)
require.ErrorIs(t, err, ErrUnavailable)
b.mu.Lock()
b.lookupErr = nil
b.mu.Unlock()
ac, err := v.Resolve(context.Background(), token)
require.NoError(t, err, "recovery must not wait out a cached failure")
assert.Equal(t, 42, ac.UserID)
}
// ---------------------------------------------------------------------------
// The cache
// ---------------------------------------------------------------------------
func TestResolveAsksMetaOncePerTokenPerTTL(t *testing.T) {
// The reason the cache exists: one federated query fans out across a service's
// resolvers, and each of them would otherwise be a pair of lookups.
v, b := newValidator(t)
token := pat(covReports)
for range 5 {
_, err := v.Resolve(context.Background(), token)
require.NoError(t, err)
}
lookups, revChecks := b.counts()
assert.Equal(t, 1, lookups)
assert.Equal(t, 1, revChecks)
}
func TestTheCacheExpires(t *testing.T) {
now := time.Now()
clock := func() time.Time { return now }
v, b := newValidator(t, func(o *Options) {
o.CacheTTL = time.Minute
o.Now = clock
})
token := pat("")
_, err := v.Resolve(context.Background(), token)
require.NoError(t, err)
now = now.Add(time.Minute) // exactly at the boundary: no longer reusable
_, err = v.Resolve(context.Background(), token)
require.NoError(t, err)
lookups, _ := b.counts()
assert.Equal(t, 2, lookups, "an expired entry must be re-resolved")
}
func TestDistinctTokensDoNotShareAnEntry(t *testing.T) {
v, b := newValidator(t)
_, err := v.Resolve(context.Background(), pat(covReports))
require.NoError(t, err)
_, err = v.Resolve(context.Background(), pat("bench.sr.ht/RESULTS"))
require.NoError(t, err)
lookups, _ := b.counts()
assert.Equal(t, 2, lookups)
}
func TestForgetDropsACachedResolution(t *testing.T) {
// For the service that learns out of band that a credential has changed and
// would otherwise keep honouring it for the rest of the TTL.
v, b := newValidator(t)
token := pat("")
_, err := v.Resolve(context.Background(), token)
require.NoError(t, err)
v.Forget(token)
_, err = v.Resolve(context.Background(), token)
require.NoError(t, err)
lookups, _ := b.counts()
assert.Equal(t, 2, lookups)
}
func TestForgettingAnUncachedTokenIsHarmless(t *testing.T) {
v, _ := newValidator(t)
assert.NotPanics(t, func() { v.Forget("never seen") })
}
func TestACallerCannotWriteThroughIntoTheCache(t *testing.T) {
// core-go's own middleware annotates the context it is handed — IPAddress is
// per-request — so handing out the cached pointer would give the next caller
// somebody else's address.
v, _ := newValidator(t)
token := pat("")
first, err := v.Resolve(context.Background(), token)
require.NoError(t, err)
first.IPAddress = "203.0.113.7"
second, err := v.Resolve(context.Background(), token)
require.NoError(t, err)
assert.Empty(t, second.IPAddress)
assert.NotSame(t, first, second)
}
func TestCopyOfPassesNilThrough(t *testing.T) {
// cached() and remember() both call it, and a nil there would be a bug
// elsewhere; passing it through rather than dereferencing keeps that bug
// reported where it happens instead of here.
assert.Nil(t, copyOf(nil))
}
func TestTheCacheIsBounded(t *testing.T) {
// Not a tuning knob: reaching the bound means a caller minting a token per
// request, and the answer is to drop everything rather than to spend the
// request budget evicting.
v, _ := newValidator(t)
for i := range maxCacheEntries + 1 {
token := seal(fmt.Sprintf("user%d", i), metaClientID, "", time.Now().Add(time.Hour))
_, err := v.Resolve(context.Background(), token)
require.NoError(t, err)
}
v.mu.Lock()
defer v.mu.Unlock()
assert.LessOrEqual(t, len(v.cache), maxCacheEntries)
assert.NotEmpty(t, v.cache, "the entry that hit the bound is still cached")
}
func TestResolveIsSafeUnderConcurrency(t *testing.T) {
v, _ := newValidator(t)
token := pat(covReports)
var wg sync.WaitGroup
for range 32 {
wg.Add(1)
go func() {
defer wg.Done()
ac, err := v.Resolve(context.Background(), token)
assert.NoError(t, err)
assert.NotNil(t, ac)
}()
}
wg.Wait()
}
// ---------------------------------------------------------------------------
// Allows
// ---------------------------------------------------------------------------
func TestAllowsHonoursTheScope(t *testing.T) {
v, _ := newValidator(t)
ac, err := v.Resolve(context.Background(), pat(covReports+":RO"))
require.NoError(t, err)
assert.True(t, Allows(ac, covReports, auth.RO))
assert.False(t, Allows(ac, covReports, auth.RW), "a read grant is not a write grant")
assert.False(t, Allows(ac, "bench.sr.ht/RESULTS", auth.RO), "another service's scope is not this one")
}
func TestAllowsPassesACallerWithNoOAuthGrants(t *testing.T) {
// A cookie session, an anonymous request, or a working token resolved by the
// other plane. None of them was ever scoped in meta's vocabulary, so there
// is nothing here to judge; what they may see is the service's own matrix.
assert.True(t, Allows(nil, covReports, auth.RO))
assert.True(t, Allows(&auth.AuthContext{}, covReports, auth.RO))
assert.True(t, Allows(&auth.AuthContext{AuthMethod: auth.AUTH_COOKIE}, covReports, auth.RW))
}
func TestAllowsAcceptsAWriteGrantForARead(t *testing.T) {
v, _ := newValidator(t)
ac, err := v.Resolve(context.Background(), pat(covReports+":RW"))
require.NoError(t, err)
assert.True(t, Allows(ac, covReports, auth.RO))
assert.True(t, Allows(ac, covReports, auth.RW))
}
// ---------------------------------------------------------------------------
// Scope spelling
// ---------------------------------------------------------------------------
func TestScopeAndScopeNameAreInverses(t *testing.T) {
// The two spellings a service has to keep in agreement: what it publishes in
// api-meta.json, which meta.sr.ht prefixes with the service name itself, and
// the full grant name it checks against.
full := Scope("cov.sr.ht", "REPORTS")
assert.Equal(t, covReports, full)
assert.Equal(t, "REPORTS", ScopeName(full))
}
func TestScopeNameLeavesABareScopeAlone(t *testing.T) {
// Which is what makes it safe to apply to a value that may already be bare.
assert.Equal(t, "REPORTS", ScopeName("REPORTS"))
}
func TestScopeNameTakesTheFirstSlashAsTheSeparator(t *testing.T) {
assert.Equal(t, "a/b", ScopeName("svc.sr.ht/a/b"))
}
// TestTheDocumentedUsageCompiles pins the routing shape the package comment
// prescribes, so that a change to PlaneOf or Resolve which breaks it fails here
// rather than in four services.
func TestTheDocumentedUsageCompiles(t *testing.T) {
v, _ := newValidator(t)
resolve := func(presented string) (string, error) {
switch PlaneOf(presented) {
case PlaneMeta:
ac, err := v.Resolve(context.Background(), presented)
if err != nil {
return "", err
}
if !Allows(ac, covReports, auth.RO) {
return "", fmt.Errorf("%w: %s", ErrForbidden, covReports)
}
return ac.Username, nil
case PlaneWorking:
return "working", nil
default:
return "", ErrInvalid
}
}
who, err := resolve(pat(covReports))
require.NoError(t, err)
assert.Equal(t, "bigbes", who)
who, err = resolve(workingToken())
require.NoError(t, err)
assert.Equal(t, "working", who)
_, err = resolve(pat("meta.sr.ht/PROFILE:RO"))
require.ErrorIs(t, err, ErrForbidden)
assert.True(t, strings.Contains(err.Error(), covReports))
_, err = resolve("nonsense")
require.ErrorIs(t, err, ErrInvalid)
}