package authn
import (
"context"
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-ecore/metapat"
)
// The meta.sr.ht plane as a unit: what it makes of an answer from the shared
// validator, and what each of that validator's refusals means here.
//
// graph/credential_test.go drives the same plane through a real handler with a
// real metapat.Validator behind it. These go in at the seam instead, because the
// arms that matter most — a meta.sr.ht that will not answer, a sentinel nobody
// has written yet — cannot be provoked from the outside without breaking
// something on purpose.
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
// stubPATs is an in-memory MetaValidator: it answers with whatever the test put
// in it, which is how every arm of the classification below is exercised with no
// meta.sr.ht, no network and no database.
type stubPATs struct {
ac *auth.AuthContext
err error
calls int
}
func (s *stubPATs) Resolve(context.Context, string) (*auth.AuthContext, error) {
s.calls++
if s.err != nil {
return nil, s.err
}
return s.ac, nil
}
// resolvedPAT is what metapat.Resolve leaves behind for a live personal access
// token of username carrying grantString: core-go's own OAuth2 caller, with the
// grants decoded in meta's vocabulary.
//
// They are decoded through a context naming this service because
// auth.DecodeGrants reads that name to expand a grant written without one — and
// panics when nothing put it there. That is the trap metapat.Options.Service
// exists to close, met here from the other side.
func resolvedPAT(t *testing.T, username, grantString string) *auth.AuthContext {
t.Helper()
g, err := auth.DecodeGrants(
config.Context(context.Background(), ini.File{}, ConfigSection), grantString)
require.NoError(t, err)
return &auth.AuthContext{
AuthMethod: auth.AUTH_OAUTH2,
UserID: 1,
Username: username,
Email: username + "@example.org",
BearerToken: &auth.BearerToken{Username: username, Grants: grantString},
Grants: g,
}
}
// newMetaAuth builds the plane over a validator that answers every credential
// the same way, for the instance owner these tests use throughout.
func newMetaAuth(t *testing.T, ac *auth.AuthContext, resolveErr error) (*MetaAuth, *stubPATs) {
t.Helper()
stub := &stubPATs{ac: ac, err: resolveErr}
plane, err := NewMetaAuth(stub, "bigbes", ScopeRead)
require.NoError(t, err)
return plane, stub
}
// errUnknownToTheTable is a refusal this package's classification has never seen.
// It stands for the sentinel a future version of metapat adds without telling
// anybody, which is the case the fail-closed default exists for.
var errUnknownToTheTable = errors.New("metapat: something nobody has classified")
// ---------------------------------------------------------------------------
// Construction
// ---------------------------------------------------------------------------
func TestNewMetaAuthRefusesAPlaneThatCannotWork(t *testing.T) {
t.Run("a nil validator", func(t *testing.T) {
// Not the "this instance has no such plane" configuration — that one is a
// nil *MetaAuth, and graph.New refuses even that — but a plane that would
// fail every PAT while looking configured.
_, err := NewMetaAuth(nil, "bigbes", ScopeRead)
require.Error(t, err)
assert.Contains(t, err.Error(), "MetaValidator")
})
t.Run("an empty owner", func(t *testing.T) {
// Every token's account would be compared against "", so the check that
// makes this a single-owner instance would be the one silently doing
// nothing.
_, err := NewMetaAuth(&stubPATs{}, "", ScopeRead)
require.Error(t, err)
assert.Contains(t, err.Error(), "owner")
})
t.Run("an owner that is not a usable name", func(t *testing.T) {
// The same rule NewResolver applies, so the two planes cannot disagree
// about who the instance owner is.
_, err := NewMetaAuth(&stubPATs{}, "not a username", ScopeRead)
require.Error(t, err)
})
t.Run("an empty scope", func(t *testing.T) {
// metapat.Allows would be asked about the grant name "", which no token
// carries and no meta checkbox can mint: every PAT on the instance
// refused, with a message naming a permission that does not exist.
_, err := NewMetaAuth(&stubPATs{}, "bigbes", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "scope")
})
}
func TestTheOwnerAndScopeAreSpelledOnceAndReadBack(t *testing.T) {
plane, _ := newMetaAuth(t, nil, nil)
assert.Equal(t, ScopeRead, plane.Scope())
assert.Equal(t, "bigbes", plane.Owner())
// The leading '~' is a display convention, not part of the name, and both
// planes strip it — a resolver built with "~bigbes" and one built with
// "bigbes" must admit the same tokens.
tilde, err := NewMetaAuth(&stubPATs{}, "~bigbes", ScopeRead)
require.NoError(t, err)
assert.Equal(t, "bigbes", tilde.Owner())
// The two halves the instance has to agree on: what meta prefixes into a
// checkbox, and what this service checks.
assert.Equal(t, ScopeRead, metapat.Scope(ConfigSection, metapat.ScopeName(ScopeRead)))
}
// ---------------------------------------------------------------------------
// VerifyToken
// ---------------------------------------------------------------------------
func TestAPersonalAccessTokenCarryingTheScopeIsAnAgent(t *testing.T) {
plane, stub := newMetaAuth(t, resolvedPAT(t, "bigbes", ScopeRead+":RO"), nil)
p, err := plane.VerifyToken(t.Context(), "presented")
require.NoError(t, err)
assert.Equal(t, 1, stub.calls)
assert.Equal(t, KindAgent, p.Kind)
assert.Equal(t, PlaneMeta, p.Plane)
assert.Equal(t, "bigbes", p.Owner)
assert.Equal(t, 1, p.UserID, "the row id every user-scoped lookup keys off")
assert.True(t, p.CanRead())
// It may read and it may not approve: KindOwner is the human at a browser,
// and a PAT is a string any process holding it can present.
assert.False(t, p.IsOwner())
// It carries no tokens.sr.ht grants, and must not be asked for any — no PAT
// can ever be minted with spec:read, so a grant check here would refuse every
// one of them rather than scope it.
assert.NoError(t, p.Authorize(ActionRead))
assert.NoError(t, p.Authorize(ActionPropose))
}
func TestAnUngrantedPersonalAccessTokenIsUniversal(t *testing.T) {
// meta.sr.ht mints a token with no grants selected and core-go reads that as
// every permission (auth.Grants.HasAll). Refusing it here would refuse the
// commonest credential on the instance — and the one a federated query is
// most likely to arrive holding.
plane, _ := newMetaAuth(t, resolvedPAT(t, "bigbes", ""), nil)
p, err := plane.VerifyToken(t.Context(), "presented")
require.NoError(t, err)
assert.Equal(t, PlaneMeta, p.Plane)
}
func TestAPersonalAccessTokenWithoutTheScopeIsForbidden(t *testing.T) {
plane, _ := newMetaAuth(t, resolvedPAT(t, "bigbes", "meta.sr.ht/PROFILE:RO"), nil)
p, err := plane.VerifyToken(t.Context(), "presented")
require.ErrorIs(t, err, ErrMissingScope)
assert.Equal(t, http.StatusForbidden, StatusFor(err),
"403: the credential is fine, the permission is not")
assert.False(t, IsAuthFailure(err), "401 would send the holder re-minting a good token")
assert.True(t, p.IsAnonymous())
assert.Contains(t, err.Error(), ScopeRead, "the refusal must name what to go and obtain")
}
func TestAPersonalAccessTokenOfAnotherOwnerIsForbidden(t *testing.T) {
// The single-owner rule, applied to the credential every account on the
// instance can mint for itself. Without it the widest credential in existence
// would be the one that skipped the narrowest identity check.
//
// 403 and not 401, and the same sentinel a foreign working token gets: the
// token verifies and the holder is who they say they are, there is simply
// nothing on this instance to grant them, so retrying will not help.
plane, _ := newMetaAuth(t, resolvedPAT(t, "somebody-else", ScopeRead), nil)
p, err := plane.VerifyToken(t.Context(), "presented")
require.ErrorIs(t, err, ErrNotInstanceOwner)
assert.Equal(t, http.StatusForbidden, StatusFor(err))
assert.True(t, p.IsAnonymous())
// The refusal is about whose token it is and not about what it carries: this
// one holds the scope and is refused anyway, because a scope says what a
// token may do and never whose it is.
assert.NotErrorIs(t, err, ErrMissingScope)
}
func TestTheOwnerIsRecognisedThroughATildeInTheToken(t *testing.T) {
// meta.sr.ht writes usernames both ways depending on the call, and the other
// plane already strips one leading '~' before comparing. A plane that did not
// would refuse the instance owner's own token half the time.
plane, _ := newMetaAuth(t, resolvedPAT(t, "~bigbes", ScopeRead), nil)
p, err := plane.VerifyToken(t.Context(), "presented")
require.NoError(t, err)
assert.Equal(t, "bigbes", p.Owner)
}
func TestAnAbsentCredentialIsNotARefusal(t *testing.T) {
// ErrNoToken is what the caller reads as "nothing was presented" rather than
// as a rejection, and nothing is asked of meta.sr.ht for it.
plane, stub := newMetaAuth(t, nil, nil)
_, err := plane.VerifyToken(t.Context(), "")
require.ErrorIs(t, err, ErrNoToken)
assert.Zero(t, stub.calls, "nothing to validate, so nothing is asked")
}
func TestAResolvedCallerWithNoIDIsRefused(t *testing.T) {
// metapat refuses this itself; the check is kept because the seam is an
// interface, and a zero id downstream would own whichever row has an unset
// owner.
ac := resolvedPAT(t, "bigbes", ScopeRead)
ac.UserID = 0
plane, _ := newMetaAuth(t, ac, nil)
_, err := plane.VerifyToken(t.Context(), "presented")
require.ErrorIs(t, err, ErrInvalidPersonalToken)
assert.True(t, IsAuthFailure(err))
assert.Equal(t, http.StatusUnauthorized, StatusFor(err))
}
// ---------------------------------------------------------------------------
// classifyResolve — the table the surfaces turn into statuses
// ---------------------------------------------------------------------------
func TestEveryRefusalOfTheSharedValidatorIsClassified(t *testing.T) {
cases := []struct {
name string
from error
want error
status int
}{
{
name: "a forged or expired token", from: metapat.ErrInvalid,
want: ErrInvalidPersonalToken, status: http.StatusUnauthorized,
},
{
name: "a revoked token", from: metapat.ErrRevoked,
want: ErrInvalidPersonalToken, status: http.StatusUnauthorized,
},
{
// Unreachable in production — graph routes on metapat.PlaneOf before
// this plane is asked — and classified so the table is total. Were it
// ever to arrive it is a bad credential *for this plane*.
name: "a working token that reached the wrong plane", from: metapat.ErrNotOurs,
want: ErrInvalidPersonalToken, status: http.StatusUnauthorized,
},
{
// Resolve is never told a scope, so it cannot raise this; classified
// so the table is total.
name: "a scope refusal from the validator itself", from: metapat.ErrForbidden,
want: ErrMissingScope, status: http.StatusForbidden,
},
{
// The arm that has to be defended: "I could not check" is not "your
// credential is bad", and 401 here would tell every federated client
// on the instance to re-mint over a meta.sr.ht restart.
name: "meta.sr.ht unreachable", from: metapat.ErrUnavailable,
want: ErrMetaUnavailable, status: http.StatusServiceUnavailable,
},
{
// Fail-closed: a sentinel this table has never seen must read as "I
// could not decide", never as a verdict about the credential.
name: "a sentinel nobody has classified yet", from: errUnknownToTheTable,
want: ErrMetaUnavailable, status: http.StatusServiceUnavailable,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plane, _ := newMetaAuth(t, nil, tc.from)
p, err := plane.VerifyToken(t.Context(), "presented")
require.ErrorIs(t, err, tc.want)
assert.ErrorIs(t, err, tc.from, "the cause must survive for the log")
assert.Equal(t, tc.status, StatusFor(err))
assert.True(t, p.IsAnonymous(), "a refused caller carries no authority")
})
}
}
// The two planes owe the surfaces above the same three answers, so their tables
// must not drift apart. This asserts the property rather than the code: every
// refusal this plane can produce lands on exactly one of 401, 403 and 503, and
// never on 200.
func TestTheClassificationIsTotalAndUnambiguous(t *testing.T) {
for _, from := range []error{
metapat.ErrInvalid, metapat.ErrNotOurs, metapat.ErrRevoked,
metapat.ErrForbidden, metapat.ErrUnavailable, errUnknownToTheTable,
} {
plane, _ := newMetaAuth(t, nil, from)
_, err := plane.VerifyToken(t.Context(), "presented")
require.Error(t, err)
status := StatusFor(err)
assert.Contains(t,
[]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusServiceUnavailable},
status, "%v mapped to %d", from, status)
}
}