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 "/[:]". 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) }