package graph
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
"sourcecraft.dev/bigbes/sr-ht-ecore/metapat"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
)
// The credential plane of /query, end to end through the real handler chain.
//
// grant_test.go exercises the gate as a unit, over a principal somebody handed
// it. These go in at the door instead — an HTTP request carrying a credential —
// because what changed in the conversion is which credentials reach the gate at
// all, and a test written against a Principal cannot see that.
// probeQuery is a cheap read: it needs no fixture beyond the fake reader and it
// is refused before parsing when the caller has no authority, so the status is
// the whole answer.
const probeQuery = `{ spaces { ref } }`
// request builds the POST the harness would send, and hands back the recorder
// as well, for the assertions that are about a header rather than a body.
func request(t *testing.T, q string, credential func(*http.Request)) (*http.Request, *httptest.ResponseRecorder) {
t.Helper()
body, err := json.Marshal(map[string]any{"query": q})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if credential != nil {
credential(req)
}
return req, httptest.NewRecorder()
}
// personalToken mints a bearer token sealed with the instance's key but stamped
// with an OAuth client id rather than tokens.sr.ht's, carrying grantString in
// meta.sr.ht's own vocabulary — "spec.sr.ht/SPECS:RO", or "" for the ungranted
// token meta treats as universal.
//
// That is exactly what a meta.sr.ht personal access token is on this instance:
// same format, same key, different issuer. The client id is the ONLY thing that
// tells the two planes apart (see sr-ht-ecore/bearer, step 2), which is why
// routing can be one local decode and why this fixture needs nothing else to be
// a convincing PAT.
func personalToken(username, grantString string) string {
bt := &auth.BearerToken{
Version: auth.TokenVersion,
Expires: auth.ToTimestamp(time.Now().Add(time.Hour)),
Grants: grantString,
ClientID: "00000000-0000-0000-0000-00000000beef",
Username: username,
}
return bt.Encode()
}
// expiredPersonalToken is a PAT whose expiry is already in the past. It is worth
// a fixture of its own because of where it lands: auth.DecodeBearerToken checks
// expiry before it reports an issuer, so metapat.PlaneOf answers PlaneUnknown and
// this never reaches the meta plane at all — see resolveCaller's routing table.
func expiredPersonalToken(username string) string {
bt := &auth.BearerToken{
Version: auth.TokenVersion,
Expires: auth.ToTimestamp(time.Now().Add(-time.Hour)),
ClientID: "00000000-0000-0000-0000-00000000beef",
Username: username,
}
return bt.Encode()
}
// patBackend is metapat's port over the same fixed user stubUsers answers the
// working-token plane with, so both planes resolve one owner to one row and a
// test that compares them is comparing planes rather than two fixtures.
//
// It answers "not revoked" always. Revocation is metapat's own contract and is
// tested there against a backend that says otherwise; what this suite is about is
// which plane a credential reaches and what it may then read.
type patBackend struct{}
func (patBackend) LookupUser(_ context.Context, username string, out *auth.AuthContext) error {
out.UserID = 1
out.Username = username
return nil
}
func (patBackend) IsRevoked(context.Context, string, [64]byte, string) (bool, error) {
return false, nil
}
// testMetaAuth is the production meta.sr.ht plane over ecore's real
// metapat.Validator. Nothing in the signature check, the grant decoding or the
// scope comparison is faked — only the profile mirror behind them, which is the
// one step that would need a meta.sr.ht.
func testMetaAuth(t *testing.T) *authn.MetaAuth {
t.Helper()
pats, err := metapat.New(metapat.Options{
Service: authn.ConfigSection,
Backend: patBackend{},
})
require.NoError(t, err)
plane, err := authn.NewMetaAuth(pats, "bigbes", authn.ScopeRead)
require.NoError(t, err)
return plane
}
// foreignToken mints a working token belonging to somebody who is not the
// instance owner.
func foreignToken(grantString string) string {
bt := &auth.BearerToken{
Version: auth.TokenVersion,
Expires: auth.ToTimestamp(time.Now().Add(time.Hour)),
Grants: grantString,
ClientID: bearer.TokensClientID,
Username: "somebody-else",
}
return bt.Encode()
}
// A tokens.sr.ht working token carrying spec:read reads. This is the credential
// the whole conversion is for: the same one /mcp and the REST write plane take,
// so a token that works against one surface of this service works against all of
// them.
func TestWorkingTokenWithTheReadGrantReads(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, probeQuery, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+agentToken(authn.ActionRead))
})
require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
assert.Empty(t, r.errText())
assert.Contains(t, string(r.Data), "~bigbes/rfcs")
}
// The universal grant covers spec:read like any other action, so a token minted
// with "*" reads too. It is asserted separately because "*" is not a member of
// the set and a grant check written as a set lookup would refuse it.
func TestUniversalGrantReads(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, probeQuery, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+agentToken("*"))
})
require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
assert.Empty(t, r.errText())
}
// A working token that verifies but was not minted for reading is 403 and not
// 401: the credential is good and the holder is known, so retrying with it is
// pointless and what they need is a wider grant. The refusal names the grant,
// because a client that is not told which one it lacks cannot ask for it.
func TestWorkingTokenWithoutTheReadGrantIsRefused(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, probeQuery, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+agentToken(authn.ActionPropose))
})
require.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
assert.Contains(t, r.body, authn.ActionRead)
assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
}
// The unified-login cookie is not a credential on this endpoint, and the owner's
// own cookie is refused along with everybody else's. That is the half of the
// conversion a status code alone would not prove, so it is asserted twice: on
// the bare handler, and mounted under the very middleware that would resolve the
// cookie into the owner principal. The second case is the one that matters —
// resolveCaller overwrites the principal rather than inheriting it, so no
// arrangement of middleware above the mount point can promote a browser session
// into read authority here.
func TestCookieIsNotACredentialHere(t *testing.T) {
t.Run("bare handler", func(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, probeQuery, func(req *http.Request) { login(req, "bigbes") })
assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
})
t.Run("under a router that does resolve the cookie", func(t *testing.T) {
resolver := testResolver(t)
srv, err := New(Options{
Reader: newFakeReader(), Searcher: &fakeSearcher{},
Resolver: resolver, Meta: testMetaAuth(t),
})
require.NoError(t, err)
// The cookie plane, installed above the endpoint. It resolves the
// owner's cookie to authn.KindOwner, which CanRead admits.
h := harness{handler: resolver.Middleware()(srv)}
r := post(t, h, probeQuery, func(req *http.Request) { login(req, "bigbes") })
assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
})
}
// The other plane, and the reason this endpoint has two. A federated query
// arrives from api.sr.ht carrying the client's own meta.sr.ht token, because the
// gateway forwards one Authorization header to every service it touches — so a
// personal access token has to work here, scoped in meta's vocabulary rather than
// in tokens.sr.ht's.
func TestTheReadScopeIsRequiredOfAPersonalAccessToken(t *testing.T) {
cases := []struct {
name string
grants string
status int
}{
{"the scope itself", authn.ScopeRead, http.StatusOK},
{"an explicit :RO", authn.ScopeRead + ":RO", http.StatusOK},
{"a write scope covers a read", authn.ScopeRead + ":RW", http.StatusOK},
{
// meta 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.
"no grants at all is universal, as meta defines it", "", http.StatusOK,
},
{"another service's scope", "bench.sr.ht/RESULTS:RO", http.StatusForbidden},
{"meta's own profile scope is not this one", "meta.sr.ht/PROFILE:RO", http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, probeQuery, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+personalToken("bigbes", tc.grants))
})
require.Equal(t, tc.status, r.status, "body %s", r.body)
if tc.status != http.StatusForbidden {
return
}
assert.Contains(t, r.body, authn.ScopeRead,
"a 403 must name meta's scope, not tokens.sr.ht's grant: a PAT holder "+
"sent looking for spec:read will not find a checkbox for it")
assert.NotContains(t, r.body, authn.ActionRead)
assert.Empty(t, r.header.Get("WWW-Authenticate"),
"a challenge would send the holder re-minting a token that is fine")
assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
})
}
}
// A personal access token reads as the owner's agent, and the emphasis is on
// agent: it is a bearer string any process holding it can present, forwarded
// through a gateway by whatever client asked, so reading it as authn.KindOwner
// would hand the approved branch and the webhook subscriptions to the widest
// credential on the instance. The scope says the token may read through spec at
// all; who it is stays the ACL's input.
func TestAPersonalAccessTokenReadsAsAnAgentAndNotTheOwner(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, probeQuery, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+personalToken("bigbes", authn.ScopeRead))
})
require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
assert.Contains(t, string(r.Data), "~bigbes/rfcs")
// The principal itself, since a 200 on a read cannot tell an agent from the
// owner: both may read, and only one may approve.
plane := testMetaAuth(t)
p, err := plane.VerifyToken(t.Context(), personalToken("bigbes", authn.ScopeRead))
require.NoError(t, err)
assert.True(t, p.IsAgent())
assert.False(t, p.IsOwner(), "a PAT must not reach the webhook mutations")
assert.Equal(t, authn.PlaneMeta, p.Plane)
assert.NoError(t, p.Authorize(authn.ActionRead),
"a PAT can never carry spec:read; asking for it would refuse every one of them")
}
// A personal access token belonging to another meta.sr.ht account is 403 and is
// not admitted as a second identity — the same rule, and the same status, a
// foreign working token gets.
//
// This is the check that matters most on this plane. A PAT is the credential
// every account on the instance can mint for itself, so without it the widest
// credential in existence would be the one that skipped the narrowest identity
// rule, and any user of this instance could read the whole corpus.
func TestPersonalAccessTokenOfAnotherOwnerIsRefused(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, probeQuery, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+personalToken("somebody-else", authn.ScopeRead))
})
assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
}
// The two vocabularies do not overlap, and this is the test that says so. Each
// plane is reached by the client id its token was sealed with, and neither can be
// talked into honouring the other's permission — but they refuse it differently,
// and the difference is worth pinning because it is not arbitrary.
func TestOnePlanesPermissionIsNotTheOthers(t *testing.T) {
t.Run("a PAT spelling tokens.sr.ht's grant", func(t *testing.T) {
// Well-formed in meta's grammar — a scope this service does not publish —
// so it authenticates and is refused on permission: 403.
h := newHarness(t, false)
r := post(t, h, probeQuery, func(req *http.Request) {
req.Header.Set("Authorization",
"Bearer "+personalToken("bigbes", authn.ConfigSection+"/"+authn.ActionRead))
})
assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
})
t.Run("a working token spelling meta's scope", func(t *testing.T) {
// Not even parseable: ecore's grants grammar is "<service>:<action>", and
// "spec.sr.ht/SPECS" is not a grant string in it. The credential is
// malformed rather than insufficient, so this is 401 with the challenge —
// a stricter answer than the one above, arrived at one step earlier.
h := newHarness(t, false)
r := post(t, h, probeQuery, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+agentToken(authn.ScopeRead))
})
assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
assert.NotEmpty(t, r.header.Get("WWW-Authenticate"))
})
}
// A working token belonging to another meta.sr.ht account is 403, not 401 and
// not admitted as a second identity: the token verifies and the holder is who
// they say they are, there is simply nothing on this single-owner instance to
// grant them. This is the ownerOnly rule the conversion had to keep, moved from
// a comparison against auth.AuthContext.Username to authn's own.
func TestWorkingTokenOfAnotherOwnerIsRefused(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, probeQuery, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+foreignToken(authn.ActionRead))
})
assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
}
// Every 401 carries the challenge, naming the scheme and this service's config
// section as the realm. RFC 9110 requires it, and the caller here is always a
// machine holding a bearer token: without it nothing tells the client which
// credential was refused.
func TestEveryUnauthorizedCarriesTheBearerChallenge(t *testing.T) {
h := newHarness(t, false)
cases := map[string]func(*http.Request){
"no credential": nil,
"owner's cookie": func(req *http.Request) { login(req, "bigbes") },
"a forged token": func(req *http.Request) {
req.Header.Set("Authorization", "Bearer not-a-real-token")
},
// Expired, and therefore unreadable rather than refused: DecodeBearerToken
// checks expiry before it reports an issuer, so metapat.PlaneOf cannot say
// which plane sealed this and resolveCaller sends it to the working one.
// Both planes owe it the same 401, which is exactly why that routing
// choice is safe — and this asserts the challenge survives it.
"an expired personal access token": func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+expiredPersonalToken("bigbes"))
},
}
for name, credential := range cases {
t.Run(name, func(t *testing.T) {
req, rec := request(t, probeQuery, credential)
h.handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusUnauthorized, rec.Code, "body %s", rec.Body.String())
assert.Equal(t, authn.Challenge(), rec.Header().Get("WWW-Authenticate"))
})
}
}